TCP接続でRasPiをリモート化するiPhoneアプリのプログラム例

iPhone-Remote

Raspberry Piで制御する機器をリモートで操作する方法にはいろいろとあるが、iPhoneからTCP接続してリモート化するiPhoneアプリを作成したので、そのプログラム例を紹介する。

●Raspberry Piをリモート化する方法

リモート化する方法でよく使われているのが、HTMLとJavaScriptを使ってウエブからWebIOPiでGPIOを制御する方法。HTMLを使った操作パネルの作成はそれほど面倒ではないが、JavaScriptを介してWebIOPiに繋げるワンクッションが必要で、場合によっては工夫を要するところがあり、やや面倒である。
昨年(2019年)後半に、iPhoneアプリの開発ツールとしてApple社からswiftUIが提供され、Xcodeを使って非常に簡単にGUIを含んだアプリの開発ができるようになった。またシミュレーションのひとつの方法として実機のiPhoneにインストールすることもでき、自分のアプリとして動かすだけなら無料で行える。(アプリの有効期間は7日間なので再インストールが必要。Apple Developer Program(年間11,800円)に参加すれば、制限なく利用できる)

●プログラム例の構成

programs
プログラム構成

●プログラム事例

<HomeView.swift>

Home画面

Client側としてServer側にTCP接続して、送受信を確認する画面。ここでは、接続先のIPアドレス、Port番号を入力し、接続ボタンで接続。送受信の確認のための、送信データ入力と送信ボタンがあり、その送受信データが表示される。

// HomeView.swift

import SwiftUI

struct HomeView: View {
    
    @EnvironmentObject var netCl: networksClient

    @State var IPadr: String = "192.168.1.13"
    @State var portNoS: String = "5000"
    @State var sendMsg: String = ""
    
    var body: some View {
         VStack {
            Spacer()
            VStack {
                Text("サーバーのアドレス設定")
                HStack {
                    Text("IP アドレス:")
                    TextField("IP アドレス", text: $IPadr)
                        .padding(3)
                        .border(Color.blue)
                }
                HStack {
                    Text("ポート番号:")
                    TextField("Port番号", text: $portNoS)
                        .padding(3)
                        .border(Color.blue)
                    
                    Button(action: {
                        self.netCl.open(ip: self.IPadr, port: UInt16(self.portNoS)!)
                    }) {
                        Text("接続")
                            .padding(3)
                            .foregroundColor(Color.white)
                            .background(Color.blue)
                    }
                    
                }
                HStack {
                    Text("ステータス:")
                    Text("\(self.netCl.statText)")
                    Spacer()
                    Button(action: {
                        self.netCl.close()
                    }) {
                        Text("切断")
                            .padding(3)
                            .foregroundColor(Color.white)
                            .background(Color.red)
                    }
                }
            }
            .frame(alignment: .center)
            .padding(10)
            .border(Color.black, width: 1)

            Spacer()
            
            VStack {
                HStack {
                    Spacer()
                    Text("送信内容:")
                    TextField("送信内容を入力して下さい", text: $sendMsg)
                        .autocapitalization(.none)
                        .padding(3)
                        .border(Color.blue)
                    Button(action: {
                        self.netCl.sendRcv(sText: self.sendMsg)
                        self.sendMsg = ""
                    }) {
                        Text("送信")
                        .padding(3)
                        .foregroundColor(Color.white)
                        .background(Color.blue)
                    }
                }
                List {
                    Text("送信 : \(self.netCl.sentText)")
                    Text("受信 : \(self.netCl.receivedText)")
                }
            }
            .frame(alignment: .center)
            .padding(10)
            .border(Color.black, width: 1)
            Spacer()
        }.padding(10)
        
    }
}

struct HomeView_Previews: PreviewProvider {
    static let netCl = networksClient()
    static var previews: some View {
        HomeView()
            .environmentObject(netCl)
    }
}

 

<RemoteView.swift>

iPhone-Remote

方向ボタン、値を増減させるスライダー、カウント増減などいくつかの操作機能があり、操作した結果を文字列でServer側に送る。例えば、スライダーで「Power」を0~100の間で変更して、その値が「50」の時、”PWR, 50”の文字列をServer側に送る。受信側Server(Raspberry Pi)では、”PWR”、”50”を使って、制御プログラムを実行する。このGUIは一例であって、swiftUIで用意されているものは何でも使え、この送る文字列はプログラム内で自由に変えることができる。

// RemoreView.swift

import SwiftUI

struct RemoteView: View {
    @EnvironmentObject var netCl: networksClient

    @State var powerVal: Double = 0
    @State var stepVal: Int = 0
    @State var selectVal: Int = 0
    @State var sizeVal: Int = 0
    private var sizeLbl: [String] = [
        "Small","Middle","Large"]
    
    var body: some View {
        VStack {
            VStack {
                List {
                     Text("送信: \(self.netCl.sentText)")
                     Text("受信: \(self.netCl.receivedText)")
                     Text("ステータス: \(self.netCl.statText)")
                }

                Stepper(value: $sizeVal, in: 0...2, step: 1, onEditingChanged: {_ in
                    self.netCl.sendRcv(sText: "SIZE,\(self.sizeVal)")
                }) {
                    Text("Size: \(self.sizeLbl[self.sizeVal])")
                }
                .padding()

                Stepper(value: $stepVal, in: 0...10, step: 1, onEditingChanged: {_ in
                    self.netCl.sendRcv(sText: "STEP,\(self.stepVal)")
                }) {
                    Text("Step : \(stepVal)")
                }
                .padding()
            }.padding()

            VStack {
                Button(action: {
                    self.netCl.sendRcv(sText: "F")
                }) {
                    Image(systemName: "arrowtriangle.up.fill")
                        .foregroundColor(.orange)
                        .scaleEffect(4)
                }
            }.frame(width: 300, height: 100, alignment: .center)

            HStack {
                Button(action: {
                    self.netCl.sendRcv(sText: "L")
                }) {
                    Image(systemName: "arrowtriangle.left.fill")
                        .foregroundColor(.orange)
                        .scaleEffect(4)
                }
                Spacer()
                Button(action: {
                    self.netCl.sendRcv(sText: "S")
                }) {
                    Image(systemName: "square.fill")
                        .foregroundColor(.orange)
                        .scaleEffect(4)
                }
                Spacer()
                Button(action: {
                    self.netCl.sendRcv(sText: "R")
                }) {
                    Image(systemName: "arrowtriangle.right.fill")
                        .foregroundColor(.orange)
                        .scaleEffect(4)
                }
            }.frame(width: 250, height: 100, alignment: .center)
            
            VStack {
                Button(action: {
                    self.netCl.sendRcv(sText: "B")
                }) {
                    Image(systemName: "arrowtriangle.down.fill")
                        .foregroundColor(.orange)
                        .scaleEffect(4)
                }
            }.frame(width: 300, height: 100, alignment: .center)
            
            VStack {
                Slider(value: $powerVal, in: 0...100, step:1,          onEditingChanged: {changed in
                        self.netCl.sendRcv(sText: "PWR,\(Int(self.powerVal))")
                    },
                   minimumValueLabel: Text("0"),
                   maximumValueLabel: Text("100"),
                   label: {Text("POWER")}
                )
                Text("Power : \(Int(powerVal))")
            }.padding()
        }
    }
}

struct RemoteView_Previews: PreviewProvider {
    static let netCl = networksClient()
    static var previews: some View {
        RemoteView()
            .environmentObject(netCl)
    }
}

 

<ContentView.swift>

上記のHome画面とRemote画面のタブを切り替えるプログラム

// ContentView.swift

import SwiftUI

struct ContentView: View {
    
    @EnvironmentObject var netCl: networksClient
    
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Image(systemName: "house.fill")
                    Text("HOME")
                }
            RemoteView()
                .tabItem {
                Image(systemName: "gamecontroller.fill")
                Text("REMOTE")
                }
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static let netCl = networksClient()
    static var previews: some View {
        ContentView()
            .environmentObject(netCl)
    }
}

 

尚、次のnetworkClientクラスを全体で使えるようにするために、 SceneDelegate.swiftの一部を次のように変更する必要がある。

//  SceneDelegate.swift
//  networkClient

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Create the SwiftUI view that provides the window contents.
        let netCl = networksClient()
        let contentView = ContentView().environmentObject(netCl)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

  ....... (以下省略) ...........
}

 

<NetworkClass.swift>

上記の画面のバックグラウンドで行われるTCP接続、送受信のプログラム。
class networksClientと”s”を付けているのは、swift標準のクラスnetworkClientと区別するため。init()で初期設定の引数を無くして、host, portの冗長な代入を繰り返しているのは、初期化後にhost, portを変更できるようにするためと、エラー発生を回避するため。

// NetworkClass.swift

import UIKit
import SwiftUI
import Foundation
import Network

class networksClient : ObservableObject {
    @Published var hostP: String = "192.168.1.13"
    @Published var portP: UInt16 = 5000
    @Published var sentText: String = "未送信"
    @Published var receivedText: String = "未受信"
    @Published var statText = "未接続"
 
    var connection: NWConnection

    init () {
        let ipS: String = "192.168.1.13"    // 仮のIP
        let portS: UInt16 = 5000            // 仮のPort
        self.hostP = ipS
        self.portP = portS
        let host = NWEndpoint.Host(ipS)
        let port = NWEndpoint.Port(integerLiteral: portS)
        self.connection = NWConnection(host: host, port: port, using: .tcp)
    }
    
    func open(ip: String, port: UInt16) {
        self.hostP = ip
        self.portP = port
        let host = NWEndpoint.Host(ip)
        let port = NWEndpoint.Port(integerLiteral: port)
        self.connection = NWConnection(host: host, port: port, using: .tcp)
        self.connection.stateUpdateHandler = { (newState) in
            switch(newState) {
                case .ready:
                    self.statText = "Ready"
                case .waiting(let error):
                    self.statText = "Waiting - \(error)"
                case .failed(let error):
                    self.statText = "Failed - \(error)"
                case .setup:
                    self.statText = "Setup"
                case .cancelled:
                    self.statText = "Cancelled"
                case .preparing:
                    self.statText = "Preparing"
                default:
                    self.statText = "Default"
            }
        }
        let netQueue = DispatchQueue(label: "NetworkClient")
        self.connection.start(queue: netQueue)
    }
    
    func send(sText: String) {
        let sendData = "\(sText)".data(using: .utf8)!
        connection.send(content: sendData, completion: .contentProcessed { (error) in
            if let error = error {
                self.statText = "\(#function), \(error)"
            } else {
                self.sentText = "\(sText)"
                self.statText = "Sent successfully"
            }
        })
    }

    func receive() {
        connection.receive(minimumIncompleteLength: 0, maximumLength: Int(UInt32.max)) { (data, _, _, error) in
            if let data = data {
                let rText = String(data: data, encoding: .utf8)!
                self.receivedText = "\(rText)"
                self.statText = "Received"
            } else {
                self.statText = "\(#function), Received data is nil"
            }
        }
    }
    
    func sendRcv(sText: String) {
        self.send(sText: "\(sText)")
        self.receive()
    }
    
    func close() {
        self.receivedText = ""
        connection.cancel()
    }
    
}

 

<testServerTCP.py>

Raspberry Pi側のTCP Serverプログラムの一例。
iPhoneから送られる操作文字列を受信して、その操作を実行する。このプログラム例では、送られる文字列を「,」で分離して、文字情報を得るところまでとしているが、その文字情報を使って様々な動きのプログラムを入れることができる。TCP送受信では、socketとselect、データ受信をthreadingを使って行っている。ここでGPIOの制御はWebIOPiではなく、RPi.GPIOなど一般的なモジュールを使うことができる。

# -*- coding: utf-8 -*-
import time
import socket
import select
import threading

# Receive data from Client, by multi task
svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)		# 再接続時のTCPエラーを回避
svr.bind(('', 5000))
svr.listen(10)
socks = [svr]

rcvData=""
rcvFlag = False

def recv():
    global rcvFlag
    global rcvData

    while True:
        readList, writeList, xList = select.select(socks, [], [])
        for sClnt in readList:
            if sClnt is svr:
                clnt, addr = svr.accept()
                socks.append(clnt)
            else:
                data = sClnt.recv(1024)
                rcvData = data.decode("utf-8")
                sClnt.send(("OK, Received : "+rcvData).encode('utf-8'))		# return OK to client
                if rcvData.lower().find('bye') != -1:
                    print("Bye!!!")
                    rcvFlag = False
                    socks.remove(sClnt)
                    sClnt.close()
                elif len(rcvData) > 0:
                    rcvFlag = True

            
# Start receiving data from client
recvThread = threading.Thread(target=recv)
recvThread.start()
print("Threading Start")

try:
    while True:
        if rcvFlag:	# Receive data from Client PC
            pdata = rcvData.split(",")
            rcvFlag = False	# Confirm data receive
            if len(pdata) > 1 and pdata[1].isdecimal():
                val = int(pdata[1])
            else:
                val = 0
            if pdata[0] == 'F':
                print("Forward", val)
            elif pdata[0] == "L":
                print("Left", val)
            elif pdata[0] == "R":
                print("Right", val)
            else:
                print("Others: ", pdata)
                
except KeyboardInterrupt:
    print("Keyboard Interrupt")

recvThread.deamon = True