二次元シリアルプロッタアプリの製作(Android Studio Kotlin)


以前に紹介しましたAndroidスマホ用のX-Y二次元グラフのカスタムViewとUSBシリアル通信機能を組み合わせて、スマホとRaspberry Pi Pico WをUSBシリアル通信で接続して、Raspberry Pi Pico Wから送信する2個のデータに基づいて、X-Yの二次元グラフをリアルタイム表示するスマホアプリを製作しました。使用した開発ツールは、Android Studio Giraffe 2022.3.1 Path 3で、使用したプログラム言語はKotlinです。

アプリの動作は、Raspberry Pi Pico Wから以下の形でグラフのX軸とY軸のデータをUSBシリアル通信でスマホに送信するとグラフがリアルタイムに表示されます。
X:「Xデータ」,Y:「Yデータ」「ターミネータ(”r¥n”)」
例 ”X:12.34,Y:2.452\r\n”

また、このアプリは、以前紹介しましたパソコンのX-YシリアルプロッタソフトのAndroidスマホ版に相当します。
パソコン版のX-Yシリアルプロッタソフトに関しては機能を盛り込みすぎたために、プログラムが煩雑になりすぎて、ソフトウェアのコード等を紹介出来ませんでした。今回作成したスマホ版に関しては機能を限定して簡略化したので、その詳細を紹介します。

・パソコンのX-Yシリアルプロッタソフトに関してはこちらの記事を参考にしてください。
・スマホのUSBシリアル通信に関してはこちらの記事を参考にして下さい。

1.アプリ使用例の動画

今回作成したアプリの使用例の動画です。スマホとRaspberry Pi Pico WをUSB接続してRaspberry Pi Pico W側からデータを送信することによって二次元のグラフを描いています。

例は以下3種類です。
(1)Raspberry Pi Pico Wでグラフに円を描くデータを作成してそれをスマホに送りグラフ描画
(2)Raspberry Pi Pico Wで2個のステッピングモータを制御し、直線補間を組み合わせた動作を行い、その2個のステッピングモータのステップ位置を描画
(3)Raspberry Pi Pico Wで2個のステッピングモータを制御し、円弧補間を組み合わせた動作を行い、その2個のステッピングモータのステップ位置を描画

Raspberry Pi Pico W側のプログラムに関しては、(1)は本記事に掲載しています。(2)、(3)は以前の記事で紹介したプログラムをそのまま使用しています。
以前の記事は以下を参照してください。

Raspberry Pi Pico Wを使用した直線補間に関する記事はこちら
Raspberry Pi Pico Wを使用した円弧補間に関する記事はこちら

2.スマホとRaspberry Pi Pico WのUSB接続に関して

使用したスマホにはType CのUSBコネクタが付いていますので、Type CからType A (メス)への変換ケーブル(OTGケーブル)を使用しました。それとType AとType micro-BのUSBケーブルを接続してスマホとRaspberry Pi Pico Wを接続しました。(図1)

図1.スマホとRaspberry Pi Pico WのUSB接続

3.usb-serial-for-androidに関して

USBシリアル通信を行うのに、usb-serial-for-androidのライブラリを使用しました。(詳細はhttps://github.com/mik3y/usb-serial-for-androidを参照して下さい)
以下でusb-serial-for-androidのライブラリを使用する手順に関して説明します。

4.usb-serial-for-androidを使用する手順

1.settings.gradle.kts(Project Settings)の設定
app→Gladle Scripts→settings.gradle.kts(Project Settings)のdependencyResolutionManagementのrepositories内に、
maven { url = uri(“https://jitpack.io”) }
を追加します。(下記6行目)

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

2.build.gradle.kts(Module:app)の設定
app→Gladle Scripts→build.gradle.kts(Module:app)のdependencies内に
implementation(“com.github.mik3y:usb-serial-for-android:3.8.1”)
を追加します。(下記7行目)

dependencies {

    implementation("androidx.core:core-ktx:1.9.0")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("com.google.android.material:material:1.12.0")
    implementation("androidx.constraintlayout:constraintlayout:2.2.0")
    implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
3.AndroidManifest.xmlの設定
スマホのUSBにRaspberry Pi Pico Wが接続された場合に、今回作成したアプリの選択画面を表示するために、app→manifests→AndroidManifest.xmlに以下の記述を追加します。

<intent-filter>~</intent-filter>タグ内に
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
を追加します。(下記6行目)

また、
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
を以下の様に追加します。(下記10行目~12行目)
<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <meta-data
        android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
        android:resource="@xml/device_filter" />
</activity>

4.device_filter.xmlの作製
Raspberry Pi Pico WのUSBのVendor IDは0x2e8a (11914)、Product IDは0xf00a (61450)です。
これをapp→res→xmlの下にdevice_filter.xmlと言う名称のxmlファイルを作成して、以下の様に記述することによって、スマホのUSBにRaspberry Pi Pico Wが接続された場合に、今回作成したアプリの選択画面を表示することができます。そのアプリ名称を選択しタップすると、アプリが起動されます。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--Raspberry Pi Pico W USB serial-->
    <usb-device vendor-id="11914" product-id="61450" /> <!--0x2e8a / 0xf00a: Pico W -->
</resources>

5.Kotlinプログラム

作成したアプリは、全体的な処理(USBシリアル通信データの受信や、二次元グラフへの描画)を記述したMainActivityクラスと、二次元グラフのカスタムViewを記載したArtViewクラスから構成されています。以下でMainActivityクラスとArtViewクラスに関して説明します。

5-1、MainActivityクラス(全体的な処理)

MainActivityクラスではUSBシリアル通信ポートの接続、データ受信、カスタムView(二次元グラフ)に受信したデータをプロットする動作をおこないます。その部分のソースコードの主要要素部分を以下に示します。

USBシリアル通信ポートから受信するデータは以下の形をしています。
X:「Xデータ」,Y:「Yデータ」「ターミネータ(”r¥n”)」
例 ”X:12.34,Y:2.452\r\n”

①USBシリアル通信の接続部分のソースコード

USBシリアル通信を接続する部分のKotlinのソースコードを以下に示します。

    //----USBシリアル通信接続の実施-------
    //Raspberry Pi Pico WのベンダーIDとプロダクトID
    val VENDOR_ID: Int = 0x2e8a     //11914
    val PRODUCT_ID: Int = 0xf00a    //61450
    //シリアルポート
    var port: UsbSerialPort? = null
    
    fun serialConnect(): Boolean {
        val usbManager: UsbManager = getSystemService(Context.USB_SERVICE) as UsbManager
        var driver: UsbSerialDriver? = null
        for (dev in UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)) {
            val device: UsbDevice = dev.device
            //ベンダIDとプロダクトIDがPico Wと一致することを確認
            if ((device.vendorId == VENDOR_ID) && (device.productId == PRODUCT_ID)) {
                driver = dev
                break
            }
        }

        if (driver == null) {
            return false
        } else {
            if (usbManager.hasPermission(driver.device) == true) { //USB使用許可確認
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションOK", Toast.LENGTH_LONG).show()
                }
            } else {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションNG", Toast.LENGTH_LONG).show()
                }
                return false
            }
        }

        val connection = usbManager.openDevice(driver!!.device)
        if (connection == null) {
            return false
        }

        port = driver!!.ports[0]
        port?.open(connection)
        port?.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
        port?.dtr = true    //Dtrをtrueにする
        port?.rts = true    //Rtsをtrueにする

        return true
    }

【説明】
3行目、4行目:
val VENDOR_ID: Int = 0x2e8a
val PRODUCT_ID: Int = 0xf00a
でRaspberry Pi Pico WのUSBのVendor IDとProduct IDを定義しています。

14行目、15行目:
device.vendorIdでUSB接続されているデバイスのVendor IDが得られます。
device.productIdでUSB接続されているデバイスのProduct IDが得られます。
Vendor IDとProduct IDがRaspberry Pi Pico Wのそれと一致する場合に、そのUSBシリアルドライバを選びます。

23行目:
if (usbManager.hasPermission(driver.device) == true) {
でUSBの使用許可が取れているかの確認をします。

前述したAndroidManifest.xmlとdevice_filter.xmlの記述がされている場合、スマホのUSBにRaspberry Pi Pico Wが接続された場合に、今回作成したアプリの選択画面が表示されます。
その時に、そのアプリ名称を選択しタップすると、アプリによるUSBの使用が許可された上でアプリが起動されますので、その手順を経た場合は、usbManager.hasPermission(driver.device)はtrueとなります。

何等かの状況でusbManager.hasPermission(driver.device)がfalseの場合は、USBの使用許可を要求する処理が必要ですが、簡略化のため今回のアプリでは無とします。

43行目、44行目:
port?.dtr = true
port?.rts = true
Raspberry Pi Pico Wとデータをやり取りするためには、USBシリアル通信ポートの信号線であるDtrとRtsをtrueにする必要があります。

②データの送信部分のソースコード

今回作成したアプリでは使用していませんが、以下は、USBシリアル通信を使用して、例として文字列”TEST\n”を送信する場合のソースコードです。

val data: String = "TEST\n"
val byteData = data.toByteArray()  //バイト配列にする
port?.write(byteData, 1000)        //データ送信

【説明】
1行目、2行目:
val data: String = “TEST\n”
val byteData = data.toByteArray()
で、文字列 “TEST\n”を8bitのバイナリに変換してバイトアレイにします。

3行目:
port?.write(byteData, 1000)
でデータを送信します。第1引数は送信するデータのバイトアレイ、第2引数はオーバタイム時間(msec単位)です。

③データの受信部分のソースコード

以下は、USBシリアル通信を使用して、データを受信する部分のソースコードです。

val rcvBuffer: ByteArray = ByteArray(100)  //受信バッファ
val numBytes = port?.read(rcvBuffer, 20)

【説明】
2行目:
val numBytes = port?.read(rcvBuffer, 20)
でUSBシリアル通信ポートから受信データを呼び出します。readメソッドの第1引数は、受信データを格納するバイトアレイです。第2引数はオーバタイム時間(msec単位です)

④二次元グラフプロット部分のソースコード

作製したアプリではArtViewと言う名称の二次元グラフのカスタムViewを作成しました。
このカスタムViewの詳細は5-2項を参照して下さい。
ここでは、カスタムViewのメソッドを使用して二次元グラフをプロットする部分のソースコードを示します。

val artView = findViewById<ArtView>(R.id.artView)
artView.drawPathLine(dataX, dataY)    //グラフプロット

【説明】
1行目:
val artView = findViewById<ArtView>(R.id.artView)
後術する今回作製したカスタムViewの名称はArtViewでidはartViewなので、そのカスタムViewを得ています。

2行目:
artView.drawPathLine(dataX, dataY)
のdrawPathLine()メソッドは今回作成したカスタムViewで二次元グラフにデータをプロットするメソッドです。dataX: FloatとdataY: Floatで指定した位置に点をプロットして、前回のプロット点との間を線で結びます。

MainActivityクラスの全ソースコード

以下にMainActivityクラスの全ソースコードを示します。データの受信部分は、スレッドで行っています。

このプログラムの動作は、アプリ画面の「接続」ボタンを押すとUSBシリアル通信が接続され、Raspberry Pi Pico Wからのデータに応じて二次元グラフが表示されます。「切断」ボタンを押すとUSBシリアル通信が切断されます。「CLEAR」ボタンを押すと描画されたグラフが消されます。

class MainActivity : AppCompatActivity() {
    //Raspberry Pi Pico WのベンダーIDとプロダクトID
    val VENDOR_ID: Int = 0x2e8a     //11914
    val PRODUCT_ID: Int = 0xf00a    //61450
    //シリアルポート
    var port: UsbSerialPort? = null
    //接続フラグ
    var connectFlag: Boolean = false
    //停止フラグ
    var stopFlag: Boolean = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //-----押し釦スイッチ関係リスナ----------
        //接続ボタンのonClickListenerを設定
        val connectButton = findViewById<Button>(R.id.connectButton)
        connectButton.setOnClickListener(ConnectButtonOnClickListener())
        //切断ボタンのonClickListenerを設定
        val disconnectButton = findViewById<Button>(R.id.disconnectButton)
        disconnectButton.setOnClickListener(DisconnectButtonOnClickListener())
        //クリアボタンのonClickListenerを設定
        val clearButton = findViewById<Button>(R.id.clearButton)
        clearButton.setOnClickListener(ClearButtonOnClickListener())
    }

    //接続ボタン押された時のonClickListener
    private inner class ConnectButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            if (connectFlag == false) { //接続中?
                //シリアル通信接続と受信スレッドスタート
                stopFlag = false    //停止フラグクリア
                val connectThread = ConnectThread()
                connectThread.start()
            }
        }
    }

    //切断ボタン押された時のonClickListener
    private inner class DisconnectButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            stopFlag = true     //停止フラグセット
        }
    }

    //クリアボタン押された時のonClickListener
    private inner class ClearButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            val artView = findViewById<ArtView>(R.id.artView)
            artView.drawClr()       //プロット点のクリア
        }
    }

    //接続と受信スレッド
    private inner class ConnectThread() : Thread() {
        private val RCV_BUF_SIZE: Int = 1024                        //受信バッファサイズ
        private val rcvBuffer: ByteArray = ByteArray(RCV_BUF_SIZE)  //受信バッファ
        private var rcvString: String? = null                       //受信データ文字列

        public override fun run() {
            serialConnect()     //USBシリアル通信接続の実施

            runOnUiThread {
                Toast.makeText(this@MainActivity, "USBシリアル通信接続完了", Toast.LENGTH_LONG).show()
            }

            connectFlag = true      //接続中フラグセット

            var numBytes: Int? = 0
            while (true) {
                try {
                    numBytes = port?.read(rcvBuffer, 20)
                }catch (e: IOException){
                    connectFlag = false //接続中フラグクリア
                    break
                }

                if (numBytes != 0) {
                    //受信データを文字列に変換する
                    var rcvData: String = String(rcvBuffer, 0, numBytes!!)

                    //前回までに受信したデータと結合
                    if (rcvString == null) {
                        rcvString = rcvData
                    } else {
                        rcvString = rcvString + rcvData
                    }

                    //ターミネータ"\r\n"検索
                    val termPos: Int = rcvString?.lastIndexOf("\r\n")!!
                    if (termPos != -1) {  //ターミネータ"\r\n"存在?
                        //"X:"を検索する
                        val XPos: Int = rcvString?.lastIndexOf("X:")!!
                        //"Y:"を検索する
                        val YPos: Int = rcvString?.lastIndexOf("Y:")!!

                        //"X:"と"Y:"が存在して、"X:"が"Y:"より前に存在
                        if ((XPos != -1) && (YPos != -1) && (XPos < YPos)) {
                            //"X:"と"Y:"の後の数値の部分を抜き出す
                            val dataX = rcvString?.substring(XPos + 2, YPos - 1)!!.toFloat()
                            val dataY = rcvString?.substring(YPos + 2, rcvString?.length!!)!!.toFloat()
                            val artView = findViewById<ArtView>(R.id.artView)
                            runOnUiThread {     //UIスレッド
                                artView.drawPathLine(dataX, dataY)    //グラフプロット
                            }
                        }
                        //受信データストリングクリア
                        rcvString = null
                    }
                }

                if (connectFlag == false){  //接続中?
                    break
                }

                if (stopFlag == true){  //停止要求?
                    break
                }
            }

            port?.close()
            connectFlag = false     //接続中フラグクリア
        }
    }

    //----USBシリアル通信接続の実施-------
    fun serialConnect(): Boolean {
        val usbManager: UsbManager = getSystemService(Context.USB_SERVICE) as UsbManager
        var driver: UsbSerialDriver? = null
        for (dev in UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)) {
            val device: UsbDevice = dev.device
            //ベンダIDとプロダクトIDがPico Wと一致することを確認
            if ((device.vendorId == VENDOR_ID) && (device.productId == PRODUCT_ID)) {
                driver = dev
                break
            }
        }

        if (driver == null) {
            return false
        } else {
            if (usbManager.hasPermission(driver.device) == true) { //USB使用許可確認
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションOK", Toast.LENGTH_LONG).show()
                }
            } else {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションNG", Toast.LENGTH_LONG).show()
                }
                return false
            }
        }

        val connection = usbManager.openDevice(driver!!.device)
        if (connection == null) {
            return false
        }

        port = driver!!.ports[0]
        port?.open(connection)
        port?.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
        port?.dtr = true    //Dtrをtrueにする
        port?.rts = true    //Rtsをtrueにする

        return true
    }

5-2.ArtViewクラス(二次元グラフのカスタムView)

作成したアプリでは、二次元グラフを表示するカスタムViewを作成しました。
作製したカスタムViewの名称と、Publicなプロパティとメソッドを表1と表2に示します。

◎カスタムViewの名称: ArtView

◎プロパティ

プロパティ役割
dpPerValue1: Floatデータ値1.0に対して、グラフを描く時にViewの画面上で何dpとするかの値です。

この値を変更することによって、グラフの拡大、縮小ができます。
xOffset: Float二次元グラフのX軸原点(中心点)の値です。

この値を変更することによって、グラフの横方向のスクロールができます。
yOffset: Float二次元グラフのY軸原点(中心点)の値です。

この値を変更することによって、グラフの縦方向のスクロールができます。
表1.カスタムViewのPublicなプロパティ

◎メソッド

メソッド役割
drawPathLine(xData: Float, yData: Float)折れ線グラフに指定されたデータを追加して描きます。
(引数)
xData
X軸方向のデータ値
yData
Y軸方向のデータ値
drawClr()描かれた折れ線グラフをクリアします。
表2.カスタムViewのPublicなメソッド

5-3.ArtViewクラス(カスタムView)のKotlinプログラム

二次元グラフを表示するカスタムView(ArtViewクラス)のKotlinプログラムの主要要素を4つに分割して示します。

①クラス定義、プロパティ、onDraw()メソッド部分のコード

カスタムViewのクラス定義と、プロパティ定義と、AndroidがViewを表示、変更する時とinvalidate()メソッドを実効した時に呼び出されるonDraw()メソッド部分のソースコードを以下に示します。onDraw()メソッド内に全ての描画処理を記述します。

class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs) {
    private val xCent: Float = 200f  //X軸中心位置(200dp)
    private val yCent: Float = 200f  //Y軸中心位置(200dp)
    private val dens = resources.displayMetrics.density  //画面論理密度
    var dpPerValue1: Float = 0.2f   //データ値1.0対する画面のdp(0.2dp)
    var xOffset: Float = 0f         //X軸のオフセット
    var yOffset: Float = 0f         //Y軸のオフセット

    //Pathの初期プロットのフラグ
    private var firstPlotFlag: Boolean = true

    //プロットデータのデータクラス宣言
    data class dataPlot(val x: Float, val y: Float){
        var xData = x
        var yData = y
    }
    var plotDatas: ArrayList<dataPlot> = ArrayList()

    private val path = Path()

    //プロット線用の描画スタイル定義
    private val pLine = Paint().apply {
        //各プロパティ定義
        color = Color.GREEN
        strokeWidth = 2f * dens
        style = Paint.Style.STROKE
        strokeJoin = Paint.Join.ROUND
    }

    //Viewが表示、変更される時に呼び出されるメソッド
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //ビューに対して描画
        canvas.drawColor(Color.WHITE)   //canvasの色指定
        canvas.drawPath(path, pLine)    //Pathラインの表示
        scaleDisp(canvas)               //スケール表示
    }





 

【説明】
1行目:
class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs)
はカスタムViewのクラス宣言です。カスタムViewはViewクラスを継承します。
カスタムViewクラスの名称をArtViewとしました。

2行目~7行目:
主なプロパティに関して下表で説明します。

プロパティ名役割
private val xCent: Float = 200fグラフのX軸座標の中心のView画面上の位置です。(単位dp)
private val yCent: Float = 200fグラフのY軸座標の中心のView画面上の位置です。(単位dp)
private val dens = resources.displayMetrics.density画面の論理密度です。
resources.displayMetrics.density
で得られる論理密度は、1インチ当たりのピクセル数(dpi)を160で割り算したものです。
dp値に、このdensを乗算することによってピクセル値に変換することが出来ます。
var dpPerValue1: Float = 0.2fグラフをプロットする時、データ1.0に対して、画面上で何dpとするかの値です。
var xOffset: Float = 0fX-Yの二次元座標のX軸座標の原点オフセット量(原点の値)です。グラフをX軸方向にスクロールする時に、この値を変更します。
var yOffset: Float = 0fX-Yの二次元座標のÝ軸座標の原点オフセット量(原点の値)です。グラフをÝ軸方向にスクロールする時に、この値を変更します。
表3.主なプロパティ

13行目:
data class dataPlot(val x: Float, val y: Float){}
は、プロットしたデータを記憶しておくためのデータクラスです。17行目のplotDatasのアレイリストを使用して、過去にプロットしたデータを記憶しておきます。
この記憶したデータは、グラフのスライド操作によるスクロールや、ピンチ操作時の再描画に使用します。

22行目:
private val pLine = Paint().apply{}
でグラフ線の描画のスタイルを指定しています。

31行目:
override fun onDraw(canvas: Canvas) {}
このメソッドはAndroidがViewを表示、変更する時に呼び出されます。またinvalidate()メソッドを実効すると、このメソッドが呼び出されます。このメソッド内で全ての描画の処理を行います。
引数canvas: CanvasはViewへの描画を行うオブジェクトです。

35行目:
canvas.drawColor(Color.WHITE)
はキャンバスの背景色を白に指定します。

36行目:
canvas.drawPath(path, pLine)
はpath: Passのデータにそって、pLineの描画スタイルで折れ線を表示します。
Passのデータの作製に関しては②項で説明します。

メソッド内容
fun drawPath(path: Path, paint: Paint)Pathに沿って図形描画する
(引数)
path: パス
paint: 描画スタイル

37行目:
scaleDisp(canvas)
はX-Y二次元グラフのX軸線とY軸線とそれぞれの目盛値を表示するメソッドです。ソースコードは④項を参照してください。

②データからのPathの構築・描画と描画クリア部分のコード

drawPathLine()メソッドは、引数で指定したXデータとYデータをPathに追加した後に、Pathのデータにそって折れ線を描画するメソッドです。
drawClear()メソッドは、描画した折れ線をクリアするメソッドです。
それらのメソッドのソースコードを以下に示します。

    //Pathにプロット点を追加して描画
    fun drawPathLine(xData: Float, yData: Float){
        val data = dataPlot(xData, yData)
        plotDatas.add(data)   //前回までのプロットデータに新プロットデータを追加して記憶

        //X,Y値をピクセル位置に変換
        val xPix = (xCent + (-xOffset + xData) * dpPerValue1) * dens
        val yPix = (yCent + (yOffset - yData) * dpPerValue1) * dens

        if (firstPlotFlag == true){     //初期プロット点?
            firstPlotFlag = false       //初期プロット点フラグリセット
            path.moveTo(xPix, yPix)
        }
        else{
            path.lineTo(xPix, yPix)
        }

        invalidate()    //onDraw()メソッドを呼び出す
    }

    //プロットした表示全クリア
    fun drawClr(){
        plotDatas.clear()       //記憶したプロットデータをクリアする
        path.reset()            //パスのクリア
        xOffset = 0f            //X軸オフセットクリア
        yOffset = 0f            //Y軸オフセットクリア
        dpPerValue1 = 0.2f      //データ1.0に対する画面のdp値初期セット
        firstPlotFlag = true    //初期プロット点フラグフラグセット
        invalidate()            //onDraw()メソッドを呼び出す
    }

【説明】
2行目:
drawPathLine()メソッドは、引数で指定されたプロット点をPathに追加した後に、Pathに従って折れ線を描画するメソッドです。

4行目:
グラフをスライド操作でスクロールする場合と、ピンチ操作で拡大・縮小をする場合にそなえて、プロットしたデータをplotDatas.add(data)でアレイリストに記憶しておきます。

7,8行目:
val xPix = (xCent + (-xOffset + xData) * dpPerValue1) * dens
val yPix = (yCent + (yOffset – yData) * dpPerValue1) * dens
でX軸方向の値とY軸方向の値を、画面のピクセル値に変換しています。

dpPerValue1は、データ値1.0の値がViewの画面上で何dpであるかを示す変数です。データ値にdpPerValue1を乗算することによって、データ値に対応した画面上のdp単位での位置を計算しています。

densは画面の論理密度で、dp値に論理密度を乗算することによって、ピクセル値を得ることができます。

10行目~16行目:
グラフにプロットする座標値(X軸方向のピクセル位置とY軸方向のピクセル位置)をPathに追加します。
最初のデータは、moveTo()メソッドを使用してPathに追加します。途中経過のデータはlineTo()メソッドを使用してPathに追加します。moveTo()メソッドとlineTo()メソッドに関して表4に示します。
firstPlotFlagを使用してどちらのメソッドを使用するか判断しています。

メソッド内容
fun moveTo(x:Float, y: Float)パスの最初の点となる座標を指定
(引数)
x: X座標値(単位はピクセル)
y: Y座標値(単位はピクセル)
fun lineTo(x: Float, y: Float)パスの終点または経由点の座標を指定
続けて呼び出すことによって、連続した複数の直線(折れ線)を表現出来る
(引数)
x: X座標値(単位はピクセル)
y: Y座標値(単位はピクセル)
表4.moveToメソッドとlineToメソッド

18行目:
invalidate()メソッドを呼び出すと、onDraw()メソッドが呼び出されて再描画が行われます。

22行目:
drawClr()メソッドは、描画された折れ線をクリアするメソッドです。

24行目、29行目:
描画した折れ線グラフをクリアするdrawClr()メソッドでは、24行目のpath.reset()でPathをクリアした後に、29行目のinvalidate()メソッドからonDraw()メソッドを呼び出して、クリアしたPathを再描画することによって折れ線グラフを消しています。

③スライドによるスクロールとピンチによる拡大、縮小部分のコード

グラフのスライド操作によるスクロールと、ピンチ操作による拡大縮小に関する部分のソースコードを以下に示します。

    //----スクロールとピンチ処理---------------------
    private val mScaleDetector = ScaleGestureDetector(context, ScaleListener())
    private val mGestureDetector = GestureDetector(context, GestureListener())

    //画面タッチで呼び出されるメソッド
    override fun onTouchEvent(event: MotionEvent): Boolean {
        mScaleDetector.onTouchEvent(event)
        mGestureDetector.onTouchEvent(event)
        return true
    }

    //SimpleOnScaleGestureListener(ピンチ時のリスナ)
    inner class ScaleListener: ScaleGestureDetector.SimpleOnScaleGestureListener(){
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            val scale: Float = detector.scaleFactor

            //データ1.0に対する画面dpを変更する
            dpPerValue1 = dpPerValue1 * scale
            //パスの再生とグラフの再表示
            reMakePath()
            return true
        }
    }

    //SimpleOnGestureListerner(スクロール時のリスナ)
    inner class GestureListener: GestureDetector.SimpleOnGestureListener(){
        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            //X軸、Y軸のオフセット値計算
            val xOfs = xOffset + distanceX / (dens * dpPerValue1)
            //0.001以下を切り捨てる
            xOffset = (Math.floor(xOfs.toDouble() * 1000.0) / 1000.0).toFloat()
            val yOfs = yOffset - distanceY / (dens * dpPerValue1)
            //0.001以下を切り捨てる
            yOffset = (Math.floor(yOfs.toDouble() * 1000.0) / 1000.0).toFloat()
            //パスの再生とグラフの再表示
            reMakePath()
            return true
        }
    }

    //パスの再生と再表示
    fun reMakePath(){
        //今までのPathの目盛に対応した作り直し
        path.reset()        //パスのクリア
        firstPlotFlag = true
        plotDatas.forEach {
            val data = it
            val xData = data.xData
            val yData = data.yData

            //X,Y位置をピクセル位置に変換してPathを再作成
            val xPix = (xCent + (-xOffset + xData) * dpPerValue1) * dens
            val yPix = (yCent + (yOffset - yData) * dpPerValue1) * dens
            if (firstPlotFlag == true){
                firstPlotFlag = false
                path.moveTo(xPix, yPix)
            } else {
                path.lineTo(xPix, yPix)
            }
        }
        invalidate()    //onDraw()メソッドを呼び出す
    }

【説明】
2行目:
private val mScaleDetector = ScaleGestureDetector(context, ScaleListener())
のScaleGestureDetectorは、ピンチ操作による拡大、縮小イベントを検出するのに使用しています。

3行目:
private val mGestureDetector = GestureDetector(context, GestureListener())
のGestureDetectorは、スライド操作による座標の移動(スクロール)を検出するのに使用しています。

6行目:
override fun onTouchEvent(event: MotionEvent)
このメソッドは画面へ指をタッチした時に発生するイベントメソッドです。ScaleGestureDetectorとGestureDetectorのリスナを起動しています。

13行目:
inner class ScaleListener: ScaleGestureDetector.SimpleOnScaleGestureListener(){}
はピンチ操作による拡大、縮小イベントを検出するためのリスナクラスです。

15行目:
val scale: Float = detector.scaleFactor
でピンチ操作した時のスケールを得ています。この値によってグラフの1.0に対する画面dp値(dpPerValue1)を変更しています。

18行目:
dpPerValue1 = dpPerValue1 * scale
でピンチ操作時のスケール値をデータ値1.0に対する画面dp値(dpPerValue1)に乗算することによって、新たなdpPerValue1を得ています。この値を使用してグラフを再描画することによって拡大、縮小を行っています。

26行目:
inner class GestureListener: GestureDetector.SimpleOnGestureListener(){}
はスライド操作による移動量を検出するためのリスナクラスです。
X軸方向とY軸方向へのスライド距離distanceXとdistanceYから、グラフのX軸方向のオフセット値とY軸方向のオフセット値を計算しています。

34行目~36行目:
val xOfs = xOffset + distanceX / (dens * dpPerValue1)
でスライド操作によるX軸方向の移動量distanceX (ピクセル単位) からX軸方向の原点オフセット量を得ています。

xOffset = (Math.floor(xOfs.toDouble() * 1000.0) / 1000.0).toFloat()
で得られたオフセット量の0.001未満を切り捨てています。

Y軸方向に関しても同様の処理を行っています。

47行目:
fun reMakePath()はピンチ操作やスライド操作で変更された、グラフの1.0に対する画面dp値(dpPerValue1)と、グラフのX軸方向のオフセット値(xOffset)とY軸方向のオフセット値(yOffset)に基づいてグラフを再描画するメソッドです。
plotDatasアレイリストに記憶された過去のプロットデータに基づいてPathを再作成してグラフを再描画しています。

④グラフの目盛り軸の表示部分のコード

グラフの横軸(X軸)と縦軸(Y軸)と目盛の数値を表示する部分のソースコードを以下に示します。
詳細説明は省きますが、X軸とY軸の10dp毎に目盛線を描き、50dpの倍数の部分は長めの目盛線を描いています。
また、50dpの倍数の目盛線の場所に目盛値を描画しています。
尚、作製したアプリでは目盛値の少数点以下は切り捨てています。実際に使用する時は、データ値のレンジ等に応じて切り捨てる位置を変える必用があると思います。

    //--------目盛線の表示-----------------------
    fun scaleDisp(canvas: Canvas) {
        drawXaxis(canvas)       //X軸の表示
        drawYaxis(canvas)       //Y軸の表示
        drawXscale(canvas)      //X軸の目盛り表示
        drawYscale(canvas)      //Y軸の目盛り表示
    }

    //X軸(横軸)の表示
    fun drawXaxis(canvas: Canvas){
        //目盛線用描画スタイル
        val pa1 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 1.5f * dens
        }

        //目盛線の延長線用描画スタイル
        val pa2 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 0.5f * dens
        }

        //X軸を引く (中心-190dp)~(中心+190dp)
        var x1Pos = (xCent -190f) * dens
        var y1Pos = yCent * dens
        var x2Pos = (xCent + 190f) * dens
        var y2Pos = yCent * dens
        canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

        //10dp毎に目盛り線を引く
        for (i in 1..19){
            //中心から50dp,100dp,150dpは長い線
            if ((i == 5) || (i == 10) || (i == 15)){    //長めの線
                //中心から右側
                x1Pos = (xCent + i * 10) * dens
                y1Pos = (yCent - 5) * dens
                x2Pos = (xCent + i * 10) * dens
                y2Pos = (yCent + 5) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

                //中心から左側
                x1Pos = (xCent - i * 10) * dens
                x2Pos = (xCent - i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
            else {  //中心から50dp,100dp,150dp以外は短い線
                //中心から右側
                x1Pos = (xCent + i * 10) * dens
                y1Pos = (yCent - 2) * dens
                x2Pos = (xCent + i * 10) * dens
                y2Pos = (yCent + 2) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

                //中心から左側
                x1Pos = (xCent - i * 10) * dens
                x2Pos = (xCent - i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
        }

        //横軸の目盛り線延長を引く
        for (i in 1..3){    //中心から50dp,100dp,150dpの位置
            //中心から右側
            var x1Pos = (xCent -190f) * dens
            var y1Pos = (yCent + i * 50) * dens
            var x2Pos = (xCent + 190f) * dens
            var y2Pos = (yCent + i * 50) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)

            //中心から左側
            y1Pos = (yCent - i * 50) * dens
            y2Pos = (yCent - i * 50) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }
    }

    //Y軸(縦軸)の表示
    fun drawYaxis(canvas: Canvas){
        //目盛線用描画スタイル
        val pa1 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 1.5f * dens
        }

        //目盛線の延長線用描画スタイル
        val pa2 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 0.5f * dens
        }

        //Y軸を引く (中心-190dp)~(中心+190dp)
        var x1Pos = xCent * dens
        var y1Pos = (yCent - 190f) * dens
        var x2Pos = xCent * dens
        var y2Pos = (yCent + 190f) * dens
        canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

        //10dp毎に目盛り線を引く
        for (i in 1..19) {
            //中心から50dp,100dp,150dpは長い線
            if ((i == 5) || (i == 10) || (i == 15)) {   //長めの線
                //中心から上側
                x1Pos = (xCent - 5) * dens
                y1Pos = (yCent + i * 10) * dens
                x2Pos = (xCent + 5) * dens
                y2Pos = (yCent + i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

                //中心から下側
                y1Pos = (yCent - i * 10) * dens
                y2Pos = (yCent - i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            } else {    ////中心から50dp,100dp,150dp以外は短い線
                //中心から上側
                x1Pos = (xCent - 2) * dens
                y1Pos = (yCent + i * 10) * dens
                x2Pos = (xCent + 2) * dens
                y2Pos = (yCent + i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

                //中心から下側
                y1Pos = (yCent - i * 10) * dens
                y2Pos = (yCent - i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
        }

        //縦軸の目盛り線延長を引く
        for (i in 1..3){ //中心から50dp,100dp,150dpの位置
            //中心から上側
            var x1Pos = (xCent - i * 50) * dens
            var y1Pos = (yCent - 190f ) * dens
            var x2Pos = (xCent - i * 50) * dens
            var y2Pos = (yCent + 190f) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)

            //中心から下側
            x1Pos = (xCent + i * 50) * dens
            x2Pos = (xCent + i * 50) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }
    }

    //X軸の目盛り表示
    fun drawXscale(canvas: Canvas){
        //文字用描画スタイル
        val pText = Paint().apply{
            color = Color.BLACK
            strokeWidth = 5f * dens
            typeface = Typeface.SERIF
            textSize = 15f      //テキストサイズ(sp)
            textAlign = Paint.Align.CENTER  //表示位置
            textScaleX = 1.5f   //水平方向への拡大率
            textSkewX = -0.5f   //水平方向にずらす度合
        }

        for (i in 0..3){
            //中心から横へ50dp, 100dp, 150dpの位置に数値表示
            var xPos = (xCent + i * 50f) * dens
            var yPos = (yCent + 15f) * dens
            var tex = changeString(i * 50f / dpPerValue1 + xOffset)
            canvas.drawText(tex, xPos, yPos, pText)

            //中心から横へ-50dp, -100dp, -150dpの位置に数値表示
            xPos = (xCent - i * 50f) * dens
            tex = changeString(i * -50f / dpPerValue1 + xOffset)
            canvas.drawText(tex, xPos, yPos, pText)
        }

        //"X"の表示
        var xPos = (xCent + 190f) * dens
        var yPos = (yCent - 5f) * dens
        canvas.drawText("X", xPos, yPos, pText)

    }

    //Y軸の目盛り表示
    fun drawYscale(canvas: Canvas){
        //文字用描画スタイル
        val pText = Paint().apply{
            color = Color.BLACK
            strokeWidth = 5f * dens
            typeface = Typeface.SERIF
            textSize = 15f      //テキストサイズ(sp)
            textAlign = Paint.Align.LEFT  //表示位置
            textScaleX = 1.5f   //水平方向への拡大率
            textSkewX = -0.5f   //水平方向にずらす度合
        }

        for (i in 0..3){
            //中心から下へ50dp, 100dp, 150dp付近に数値表示
            var xPos = (xCent + 5f) * dens
            var yPos = (yCent + i * 50f - 5f) * dens
            var tex = changeString(i * -50f / dpPerValue1 + yOffset)
            canvas.drawText(tex, xPos, yPos, pText)

            //中心から上へ-50dp, -100dp, -150dp付近に数値表示
            yPos = (yCent - i * 50f - 5f) * dens
            tex = changeString(i * 50f / dpPerValue1 + yOffset)
            canvas.drawText(tex, xPos, yPos, pText)
        }

        //"Y"の表示
        val xPos = (xCent - 15f) * dens
        val yPos = (yCent - 185f) * dens
        canvas.drawText("Y", xPos, yPos, pText)
    }

    //0.001以下を切り捨て文字列化
    fun changeString(value: Float): String{
        //0.001以下切り捨て
//        val dataTxt = (Math.floor(value.toDouble() * 1000.0) / 1000.0).toString()
        //小数点以下切り捨て
        val dataTxt = Math.floor(value.toDouble()).toString()
        return dataTxt
    }

6.アプリ画面のレイアウト

作製したアプリ画面のレイアウトを以下に示します。
アプリ画面のレイアウトは、作成した二次元グラフのカスタムViewとButtonを3個を配置しました。表5に各Viewのidと役割に関して記載します。

図2.画面レイアウト
名称種類id役割
カスタムView
(ArtView)
二次元グラフartViewデータをリアルタイムに二次元グラフ表示
接続ボタンButtonconnectButtonUSBシリアル通信を接続して、受信データのグラフ表示を開始する
切断ボタンButtondisconnectButtonUSBシリアル通信を切断する
CLEARボタンButtonclearButton描画したグラフを消去する
表5.各Viewの役割とid

尚、二次元グラフのカスタムView (ArtView)は、下図の様にDesign画面のPaletteのContainers→<View>をデザインの画面上にドラッグすると、Viewsの選択画面が表示されるので、作成したカスタムViewの名称ArtViewを選択して「OK」をクリックすると画面上に配置することができます。

画面レイアウトに対応したXMLのコード(app→res→layout→activity_main.xml)を以下に示します。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <view
        android:id="@+id/artView"
        class="com.ri_control.usb_serial_x_y_plotter.ArtView"
        android:layout_width="400dp"
        android:layout_height="400dp"
        android:layout_marginTop="32dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/connectButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="32dp"
        android:text="接続"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/artView" />

    <Button
        android:id="@+id/disconnectButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="32dp"
        android:text="切断"
        app:layout_constraintStart_toEndOf="@+id/connectButton"
        app:layout_constraintTop_toBottomOf="@+id/artView" />

    <Button
        android:id="@+id/clearButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="32dp"
        android:layout_marginTop="32dp"
        android:text="CLEAR"
        app:layout_constraintStart_toEndOf="@+id/disconnectButton"
        app:layout_constraintTop_toBottomOf="@+id/artView" />
</androidx.constraintlayout.widget.ConstraintLayout>

7.Raspberry Pi Pico W側のプログラム

Raspberry Pi Pico Wから以下の形でX軸とY軸のデータをUSBシリアル通信でスマホに送信するとグラフがリアルタイムに表示されます。
X:「Xデータ」,Y:「Yデータ」「ターミネータ(”r¥n”)」
例 ”X:12.34,Y:245.2\r\n”

以下に、Arduino IDEを使用してArduino言語で作成した、中心(0,0)で半径400の円状となるデータをUSBシリアル通信で送信するスケッチの例を示します。

//-----二次元グラフ用データ作成-----
#define PI_DATA 3.141592653589793

void setup() {
  Serial.begin(9600);       //USBシリアル通信初期化
  pinMode(15, INPUT_PULLUP);  //GP15を入力端子に設定(スイッチ接続)
}

void loop() {
  while (true){
    //スイッチ読込(LOW判断)
    if (digitalRead(15) == LOW){
      break;
    }
  }

  for (int i = 0; i < 360; i++){
    //X軸、Y軸のデータ作成
    double x = 400 * sin(PI_DATA * (float)i / 180);
    double y = 400 * cos(PI_DATA * (float)i / 180);

    //データ送信
    String text_x = String(x);
    String text_y = String(y);
    String text = String("X:" + text_x +",Y:" + text_y);
    Serial.println(text);        //現在データをUSBシリアルに送信
    
    delay(10);     //10msec待ち
  }
}

8.最後に

今回作成したアプリでは、Raspberry Pi Pico WからUSBシリアル通信を使用してスマホへ送られたデータをX-Y二次元グラフに表示しました。
この機能はマイコンボード等から得られる2個の信号をオシロスコープのX-Yモード表示のように表示することが出来ます。
今後は本アプリの三次元拡張版にトライします。


PAGE TOP