CordovaでUDPブロードキャストを受信する

前回の投稿で、ESP32に接続したセンサ信号をUDPブロードキャストしました。

　[ESP32で計測したセンサーデータをUDPブロードキャストする](https://qiita.com/poruruba/items/e19dce57d1b73795c683)

今回は、CordovaでUDPブロードキャストを受信するプラグインを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0101d6d1-3077-3c1e-1a75-8c1bfed63e91.png)

アプリケーションは、ランダム画像を表示するクロックにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/40adf3bd-bcac-6b40-3377-1dd7e9cc9dbe.png)

クロック表示画面には、ランダム画像提供サイトから取得される画像を背景画像として表示します。
もし、UDPブロードキャストで受信する光センサの値から周りが暗いと判断した場合、就寝中あるいは外出中とみなして、画像の表示をやめて、黒背景にします。
また、UDPブロードキャストで赤外線リモコン信号を受信した場合には、画像の取得元を変更して、別の画像に切り替えられるようにしました。

もろもろのソースコードは前回と同じ以下に置きました。

LineBeaconSensor

https://github.com/poruruba/LineBeaconSensor

# スマホアプリの構成

スマホアプリとしてCordovaを採用しました。
理由は、比較的簡単にAndroidとiOS両対応のネイティブアプリを実装できるのと、プラグインが豊富であるためです。
Android、iPhone、iPadで動作を確認しました。

以下のプラグインを使いました。

* cordova-plugin-device
AndroidとiOSどちらで動いているかを判別するために利用しています。（ただし、今回はOSごとに区別した処理はありません）

* cordova-plugin-powermanagement
スマホの画面を常に表示をOnにするために利用しています。

* cordova-plugin-simpleudp
UDPブロードキャストを受信するために利用しています。
残念ながら、UDPを受信するのに都合の良いプラグインは見つからなかったため、自作しました。(後述)

# UDP受信のためのCordovaプラグイン

以下に上げておきました。

cordova-plugin-simpleudp

https://github.com/poruruba/cordova-plugin-simpleudp

プラグインの作成方法は以下を参考にします。

[Cordovaアプリ開発の備忘録(プラグイン編)](https://qiita.com/poruruba/items/a454f1b90c5d8b0cb2f8)

## Android実装

Android用のネイティブ部はこんな感じです。

```java:src\android\jp\or\sample\plugin\SimpleUdp\Main.java
package jp.or.sample.plugin.SimpleUdp;

import android.app.Activity;
import android.content.Intent;
import android.util.Log;
import org.apache.cordova.CordovaInterface;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaWebView;
import org.apache.cordova.PluginResult;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

import java.util.HashMap;

public class Main extends CordovaPlugin {
	public static String TAG = "SimpleUdp.Main";
	private Activity activity;
	private static final int DEFAULT_RECEIVE_BUFFER_SIZE = 1024;
	private HashMap<Integer, DatagramSocket> sockets = new HashMap<>();

	@Override
	public void initialize(CordovaInterface cordova, CordovaWebView webView)
	{
		Log.d(TAG, "[Plugin] initialize called");
		super.initialize(cordova, webView);

		activity = cordova.getActivity();
	}

	@Override
	public void onResume(boolean multitasking)
	{
		Log.d(TAG, "[Plugin] onResume called");
		super.onResume(multitasking);
	}

	@Override
	public void onPause(boolean multitasking)
	{
		Log.d(TAG, "[Plugin] onPause called");
		super.onPause(multitasking);
	}

	@Override
	public void onNewIntent(Intent intent)
	{
		Log.d(TAG, "[Plugin] onNewIntent called");
		super.onNewIntent(intent);
	}

	@Override
	public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) throws JSONException
	{
		Log.d(TAG, "[Plugin] execute called");
		if( action.equals("receiving") ){
			try {
				final CallbackContext callback = callbackContext;
				final int localRecvPort = args.getInt(0);
				
				if( sockets.containsKey(localRecvPort) ){
					DatagramSocket t = sockets.get(localRecvPort);
					t.close();
					sockets.remove(localRecvPort);
				}
				final DatagramSocket udpReceive = new DatagramSocket(localRecvPort);
				sockets.put(localRecvPort, udpReceive);
				
				cordova.getThreadPool().execute(new Runnable() {
					@Override
					public void run() {
						try {
							while(true){
								byte[] buff = new byte[DEFAULT_RECEIVE_BUFFER_SIZE];
								DatagramPacket packet = new DatagramPacket(buff, buff.length);
								udpReceive.setSoTimeout(0);
								udpReceive.receive(packet);
								Log.d(TAG, "received");
							
								JSONObject message = new JSONObject();
								int len = packet.getLength();
								String payload = new String(buff, 0, len);
								message.put("payload", payload);
								byte[] address = packet.getAddress().getAddress();
								message.put("ipaddress", (((long)(address[0] & 0x00ff)) << 24) | (((long)(address[1] & 0x00ff)) << 16) | (((long)(address[2] & 0x00ff)) << 8) | (((long)(address[3] & 0x00ff)) << 0) );
								message.put("port", packet.getPort());
								
								final PluginResult result = new PluginResult(PluginResult.Status.OK, message);
								result.setKeepCallback(true);
								callback.sendPluginResult(result);
							}
						} catch (Exception ex) {
							callbackContext.error(ex.getMessage());
							if( udpReceive != null )
								udpReceive.close();
						}
					}
				});
			
			
			}catch(Exception ex){
				Log.d(TAG, ex.getMessage());
				callbackContext.error("Invalid arg0(int)");
				return false;
			}


		}else
		if( action.equals("send") ){
			try{
				String message = args.getString(0);
				String host = args.getString(1);
				int port = args.getInt(2);

				cordova.getThreadPool().execute(new Runnable() {
					@Override
					public void run() {
						try {
							DatagramSocket udpSend = new DatagramSocket();
							byte[] buff = message.getBytes();
							InetAddress inetAddress = InetAddress.getByName(host);
							DatagramPacket packet = new DatagramPacket(buff, buff.length, inetAddress, port);
							udpSend.send(packet);
							Log.d(TAG, "sended");
							udpSend.close();
							
							callbackContext.success();
						} catch (Exception ex) {
							callbackContext.error(ex.getMessage());
						}
					}
				});
				callbackContext.success();
			}catch(Exception ex){
				Log.d(TAG, ex.getMessage());
				callbackContext.error("Invalid arg0(String), arg1(String), arg2(int)");
				return false;
			}
		}else {
			String message = "Unknown action : (" + action + ") " + args.getString(0);
			Log.d(TAG, message);
			callbackContext.error(message);
			return false;
		}

		return true;
	}
}
```

## iOS実装

iOSではこんな感じに実装しました。

（一点はまったのは、UDPパケットの1回目の受信は問題なかったのですが、2回目の受信でエラーが発生していました。理由はわかりませんが、試行錯誤して、``` newConnection.forceCancel()``` を呼ぶようにしたらエラーが発生しなくなりました。）

```swift:src\ios\SimpleUdpPlugin.swift
import Foundation
import Network

@objc(SimpleUdpPlugin)
class SimpleUdpPlugin : CDVPlugin
{
    var sockets:[UInt16:NWListener] = [:]
    
	override
    func pluginInitialize() {
    }
    
    @objc(receiving:)
    func receiving(command: CDVInvokedUrlCommand){
        // port
        NSLog("receiving called")
        guard let port = command.arguments[0] as? UInt16 else {
            let pluginResult:CDVPluginResult = CDVPluginResult(status:CDVCommandStatus_ERROR, messageAs: "Parameter invalid")
            self.commandDelegate.send(pluginResult, callbackId: command.callbackId)
            return
        }

        do {
            if let listener = self.sockets[port] {
                listener.cancel()
            }
            let localPort = NWEndpoint.Port(rawValue: port)
            let listener = try NWListener(using: .udp, on: localPort!)
            listener.stateUpdateHandler = { state in
                switch state {
                case .ready:
                    print("listener ready")
                case .failed(let error):
                    print("listener2 error：　\(error)")
                    let pluginResult:CDVPluginResult = CDVPluginResult(status:CDVCommandStatus_ERROR, messageAs: "listener failed")
                    self.commandDelegate.send(pluginResult, callbackId: command.callbackId)
                    listener.cancel()
                default:
                    break
                }
            }
            
            listener.newConnectionHandler = { newConnection in
                newConnection.start(queue: .global())
                newConnection.receiveMessage { (data, context, isComplete, error ) in
                    if let data = data {
                        if let receivedString = String(data: data, encoding: .utf8){
                            print("receive message:　\(receivedString)")
                            newConnection.forceCancel()

                            let pluginResult = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: ["payload": receivedString])
                            pluginResult?.keepCallback = true
                            self.commandDelegate.send(pluginResult, callbackId: command.callbackId)
                        }else if let error = error {
                            print("receive error: \(error)")
                        }
                    }
                }
            }
            listener.start(queue: .global())
            self.sockets[port] = listener
        }catch{
            let pluginResult:CDVPluginResult = CDVPluginResult(status:CDVCommandStatus_ERROR, messageAs: "unknown")
            self.commandDelegate.send(pluginResult, callbackId: command.callbackId)
        }

    }

    @objc(send:)
    func send(command: CDVInvokedUrlCommand)
    {
        // message, host, port
        NSLog("send called")
        guard let message = command.arguments[0] as? String, let host = command.arguments[1] as? String, let port = command.arguments[2] as? UInt16 else {
            NSLog("Parameter invalid")
            let pluginResult:CDVPluginResult = CDVPluginResult(status:CDVCommandStatus_ERROR, messageAs: "Parameter invalid")
            self.commandDelegate.send(pluginResult, callbackId:command.callbackId)
            return
        }
                    
        let sendPort = NWEndpoint.Port(rawValue: port)
        let sendHost = NWEndpoint.Host(host)
        let connection = NWConnection(host: sendHost, port: sendPort!, using: .udp)
        connection.start(queue: .global())
        if let messageData = message.data(using: .utf8){
            connection.send(content: messageData, completion: .contentProcessed{ error in
                if let error = error {
                    print("send error: \(error)")
                    let pluginResult:CDVPluginResult = CDVPluginResult(status:CDVCommandStatus_ERROR, messageAs: "send falied")
                    self.commandDelegate.send(pluginResult, callbackId: command.callbackId)
                }else{
                    print("send success")
                    let pluginResult = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: "OK")
                    self.commandDelegate.send(pluginResult, callbackId: command.callbackId)
                }
                connection.cancel()
            })
        }
    }
 }
```

あとは、Javascriptで以下のように呼び出せば、受信パケットを処理できます。
以下は、AndroidとiOSどちらも同じコードです。

```js:Cordova\WallPaperClock\www\js\start.js
            simpleudp.receiving(UDP_PORT, packet =>{
                console.log(packet);
・・・
            });
```

# UDP受信パケット

UDPパケットはこんな感じです。

```
{"type":"sensor","brightness":4095}
{"type":"button","isPressed":1,"wasPressed":1}
{"type":"ir","decode_type":3,"value_high":0,"value_low":50167935}
```

# ランダム画像の取得

以下のサイトにお世話になりました。

* https://picsum.photos/
* https://p-hold.com/
* https://picsum.photos/

これらランダム画像の取得URLは、以下のページを参考にさせていただきました。

https://digipress.info/web-services/free-placeholder-services/

# クライアントソースコード

Cordovaアプリのクライアントソースコードは、Javascriptです。

```js:Cordova\WallPaperClock\www\js\start.js
'use strict';

//const vConsole = new VConsole();
//const remoteConsole = new RemoteConsole("http://[remote server]/logio-post");
//window.datgui = new dat.GUI();

const CLOCK_UPDATE_INTERVAL = 20000;
const BRIGHTNESS_THRESHOLD = 4000;
const UDP_PORT = 12346;

const IMAGE_URL_BASE_LIST = [
    "https://unsplash.it/g/",
    "https://picsum.photos/",
    "https://p-hold.com/",
];

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        datetime_now: new Date().getTime(),
        is_dark_prev: false,
        is_dark: false,
        base_url_index: 0,
    },
    computed: {
    },
    methods: {
        toDateString: function(tim){
            const d = new Date(tim);
            const weekStr = ["日", "月", "火", "水", "木", "金", "土"];
            return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日(${weekStr[d.getDay()]})`; 
        },
        toTimeString: function(tim){
            const to2d = (d) => {
                return ("00" + d).slice(-2);
            };
            const d = new Date(tim);
            return `${to2d(d.getHours())}:${to2d(d.getMinutes())}`; 
        },
        background_view_update: function(){
            if( !this.is_dark ){
              document.body.style.backgroundImage = 'url(' + this.background_url + ')';
            }else{
              document.body.style.backgroundImage = "none";
            }
        },
        background_image_change: async function(){
            console.log("background_change called");
            var width = document.documentElement.clientWidth;
            var height = document.documentElement.clientHeight;
            var base_url = IMAGE_URL_BASE_LIST[this.base_url_index];
            this.background_url = `${base_url}/${width}/${height}?p=${new Date().getTime()}`;
            console.log("url: " + this.background_url);
            this.background_view_update();
        },
        background_check_dark: function(is_dark_now){
            if( (this.is_dark == is_dark_now) || (is_dark_now && !this.is_dark_prev)){
              this.is_dark_prev = is_dark_now;
              return;
            }
            
            this.is_dark = is_dark_now;
            this.is_dark_perv = this.is_dark;
            this.background_view_update();
        },
        background_switch_base: async function(){
            this.base_url_index = ++this.base_url_index % IMAGE_URL_BASE_LIST.length;
            this.background_image_change();
        },
                  
        onDeviceReady: async function(){
            console.log("os: " + device.platform);

            window.powermanagement.acquire();

            simpleudp.receiving(UDP_PORT, packet =>{
                console.log(packet);
                var payload = JSON.parse(packet.payload);
                if( payload.type == 'sensor'){
                    this.background_check_dark(payload.brightness > BRIGHTNESS_THRESHOLD);
                }else
                if( payload.type == "ir" ){
                    this.background_switch_base();
                }
            });
        },
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        console.log("width: " + document.documentElement.clientWidth + " height: " + document.documentElement.clientHeight);
        
        this.background_image_change();

        setInterval(() =>{
            this.datetime_now = new Date().getTime();
        }, CLOCK_UPDATE_INTERVAL);
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */
  
window.vue = new Vue( vue_options );
```

HTMLです。

```html:Cordova\WallPaperClock\www\index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
  <meta http-equiv="Pragma" content="no-cache">
  <meta http-equiv="Cache-Control" content="no-cache">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <link rel="stylesheet" href="css/start.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/spinkit/2.0.1/spinkit.min.css" />
  <script src="js/methods_bootstrap.js"></script>
  <script src="js/components_bootstrap.js"></script>
  <script src="js/components_utils.js"></script>
  <script src="js/vue_utils.js"></script>
  <script src="js/gql_utils.js"></script>

  <script src="js/remoteconsole.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vconsole/dist/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js"></script>

  <link rel="stylesheet" href="css/wallpaper.css">

  <title>Template</title>
</head>
<body class="background">
<!--
    <div id="loader-background">
      <div class="sk-plane sk-center"></div>
    </div>
-->
    <div id="top" class="container-fluid">
      <div v-bind:class="{ 'top-text-leftbottom': !is_dark, 'top-text-center': is_dark }" v-on:click="background_switch_base">
        <div>
          <span class="time_style">{{toTimeString(datetime_now)}}</span><br>
          <span class="date_style">{{toDateString(datetime_now)}}</span><br>
        </div>
      </div>
  
        <!-- for progress-dialog -->
        <progress-dialog v-bind:title="progress_title"></progress-dialog>
    </div>

    <script src="js/start.js"></script>

    <script src="cordova.js"></script>
    <script src="js/index.js"></script>
</body>
```

文字の大きさを変えたい場合は、以下のCSSを編集してください。

```css:Cordova\WallPaperClock\www\css\wallpaper.css
.background {
  width: 100%;
  height: 100vh;
  background-color: #000000;
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
}

.top-text-center {
  width: 100%;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.top-text-leftbottom {
  width: 100%;
  height: 100vh;
  display: flex;
  align-items: flex-end;
}

.top-text-leftbottom * .time_style {
  color: white;
  font-size: 150px;
}
.top-text-leftbottom * .date_style {
  color: white;
  font-size: 50px;
}

.top-text-center .time_style {
  color: white;
  font-size: 200px;
}
.top-text-center .date_style {
  color: white;
  font-size: 80px;
}
```

# コンパイル・インストール方法

cordovaがインストールされている前提です。

以下からプロジェクト一式(ZIP)をダウンロードし、ZIPを展開します。

https://github.com/poruruba/LineBeaconSensor

```
> cd LineBeaconSensor-main\Cordova\WallPaperClock
> npm install
> cordova platform add android または ios

もしiosの場合は、XcodeでSigningやOSバージョンを指定してください。
スマホをPCまたはMacに接続します。

> cordova build android または ios
> cordova run android または ios
```

参考
　[Cordovaアプリ開発の備忘録](https://qiita.com/poruruba/items/f03b1691fd0f113735b3)

以上