はじめに
最近、自分の専門分野であるデータ分析がなんかレガシー化してしまった(正確に言うとデータサイエンス系の求人は軒並み生成AIか非構造データのディープラーニングや検出系ばかりがスカウトで来る)ので、他の専門分野としてデータマイニングに行けるように試行錯誤した話です。
開発の全体像
作ったものとしては簡単に言えばWebサービス用のプログラムとUDPのプログラムをクラウドサーバにアップすれば地球の裏側からでも操作できる(理論上だけど)ラジコンだと思ってください。

こんな感じでリモコンからはUDPで操作情報が送られてセンサ情報はHTTPのPOSTで送られ、それを基にラジコンを動かして、センサ情報を何らかのデバイスに送信できるようにJSONでレスポンスを送るといった感じです。
リモコンとUDPサーバ
ではリモコンです。秋月電子さんでスイッチを買ったのでそれを使いました。ただ、ラズパイが壊れているのか、スイッチを押しても押さなくてもなんなら配線を抜いても電源がONになってしまいスイッチについては製作失敗になります。

ちなみに書いたコードはこれです
リモコン
import socket
import RPi.GPIO as GPIO
RIGHT = 3
LEFT = 2
GPIO.setmode(GPIO.BCM)
GPIO.setup(LEFT, GPIO.IN)
GPIO.setup(RIGHT, GPIO.IN)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
msg = ""
if GPIO.input(LEFT):
msg = "1"
else:
msg = "0"
msg = msg + ","
if GPIO.input(RIGHT):
msg = msg + "1"
else:
msg = msg + "0"
sock.sendto(msg.encode("utf-8"), ("10.153.229.254", 9999))
そしてそれを受け取るサーバのプログラムがこれです。
サーバ
import socket
import json
UDP_HOST = "0.0.0.0"
UDP_PORT = 9999
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_HOST, UDP_PORT))
print("start")
while True:
try:
data, addr = sock.recvfrom(65535)
dic = {}
try:
text = data.decode("utf-8").split(",")
dic["left"] = int(text[0])
dic["right"] = int(text[1])
except:
dic["left"] = 0
dic["right"] = 0
f = open("control.json", "w", encoding="utf-8")
f.write(json.dumps(dic))
f.close()
except:
_ = 0
サーバにデータが送られたときにJSONの形式にしています。
そしてこれを常に起動しておくためコマンドプロンプトを実行するためバッチファイルを作っておきました。
python UDP_sever.py
センサ
本当はCO2センサも使いたかったのですが、あいにくはんだ付けが必要だったため使いませんでした。
なので温湿度センサとGPSを使いました。
import RPi.GPIO as GPIO
import dht11
import time
import datetime
import pigpio
import requests
GPIO_RX = 18
BAUD = 9600
pi = pigpio.pi()
pi.bb_serial_read_open(GPIO_RX, BAUD, 8)
if not pi.connected:
raise SystemExit("EXIT")
def nmea_to_deciaml(coord, direction):
degrees = float(coord[:2])
minutes = float(coord[2:])
decimal = degrees + minutes / 60.0
if direction in ["S", "W"]:
decimal *= -1
return decimal
# initialize GPIO
GPIO.setwarnings(True)
GPIO.setmode(GPIO.BCM)
# read data using pin 14
instance = dht11.DHT11(pin=14)
dic = {}
ido = 0
kei = 0
try:
buf = b""
while True:
result = instance.read()
if result.is_valid():
print("Last valid input: " + str(datetime.datetime.now()))
print("Temperature: %-3.1f C" % result.temperature)
print("Humidity: %-3.1f %%" % result.humidity)
count, data = pi.bb_serial_read(GPIO_RX)
if count > 0:
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
s = line.decode("ascii", errors="ignore").strip()
if s.startswith("$GPGGA"):
parts = s.split(",")
if len(parts) > 5 and parts[2] and parts[4]:
ido = nmea_to_deciaml(parts[2], parts[3])
kei = nmea_to_deciaml(parts[4], parts[5])
#print("ido:%f, kei=%f"%(ido, kei))
dic["hum"] = result.humidity
dic["tmp"] = result.temperature
dic["ido"] = ido
dic["kei"] = kei
#print(dic)
if dic["hum"] != 0:
requests.post("http://10.153.229.254/input-sensor",data=dic)
time.sleep(0.5)
except KeyboardInterrupt:
print("Cleanup")
GPIO.cleanup()
finally:
pi.bb_serial_read_close(GPIO_RX)
pi.stop
ラジコン
次にラジコンですがタミヤのダブルギアとタンクを使いました。
実際の写真はこれです。
ちなみに回路はいたってシンプルで同僚のというか人生の大先輩で電気専門の方に「パワートランジスタでモータ動かしたいんですが・・・」って言ったら教えて頂きこんな感じになりました。

import requests
import json
import RPi.GPIO as GPIO
#7と11
LEFT = 7
RIGHT = 11
GPIO.setmode(GPIO.BOARD)
GPIO.setup(LEFT, GPIO.OUT, initial=GPIO.LOW)
GPIO.setup(RIGHT, GPIO.OUT, initial=GPIO.LOW)
while True:
res = requests.get("http://10.153.229.254/control")
dic = json.loads(res)
if dic["left"] == 1:
GPIO.output(LEFT, GPIO.HIGH)
else:
GPIO.output(LEFT, GPIO.LOW)
if dic["right"] == 1:
GPIO.output(RIGHT, GPIO.HIGH)
else:
GPIO.output(RIGHT, GPIO.LOW)
現実にNAT状況下でUDPを使うのは難しいためTCP/IPにしました。
Webサーバ
さてここからは他のすべてを司るWEBサーバ側のプログラムです。使ったのはFlaskになります。
from flask import Flask, jsonify, request, render_template
import json
app = Flask("__name__")
@app.route("/")
def dashbord():
return render_template("dashbord.html")
@app.route("/reset")
def reset():
data = {}
data["tmp"] = []
data["hum"] = []
data["x"] = []
f = open("state.json", "w", encoding="utf-8")
f.write(json.dumps(data))
f.close()
return jsonify(data)
@app.route("/control")
def route():
f = open("control.json", "r", encoding="utf-8")
jsondata = f.read()
f.close()
jsondata = json.loads(jsondata)
return jsonify(jsondata)
@app.route("/input-sensor", methods=["POST"])
def input_sensor():
hum = float(request.form["hum"])
tmp = float(request.form["tmp"])
ido = float(request.form["ido"])
kei = float(request.form["kei"])
f = open("state.json", "r", encoding="utf-8")
jsondata = f.read()
f.close()
dic = json.loads(jsondata)
hums = dic["hum"]
tmps = dic["tmp"]
x = dic["x"]
hums.append(hum)
tmps.append(tmp)
x.append(len(x))
dic2 = {}
dic2["hum"] = hums
dic2["tmp"] = tmps
dic2["x"] = x
f = open("state.json", "w", encoding="utf-8")
f.write(json.dumps(dic2))
f.close()
geo = """{ "type": "Point",
"crs": { "type": "name",
"properties": {
"name": "Current Location"
}
},
"coordinates": [%f, %f]
}
"""%(kei, ido)
f = open("point.geojson", "w", encoding="utf-8")
f.write(geo)
f.close()
return jsonify(dic2)
@app.route("/get-state")
def get_state():
f = open("state.json", "r", encoding="utf-8")
data = f.read()
f.close()
data = json.loads(data)
return jsonify(data)
@app.route("/get-geo")
def get_geo():
f = open("point.geojson", "r", encoding="utf-8")
data = f.read()
f.close()
data = json.loads(data)
return jsonify(data)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)
といった感じのプログラムです。一つずつ要点を整理しますとURLが
- /reset・・・JSONデータをリセット
- /control・・・ラジコンの操作用のJSONデータを送信
- /input-sensor・・・センサ情報のJSONデータを保存
- /get-state・・・センサ情報(温湿度)のJSONを送信
- /get-geo・・・GPSの場所(緯度経度情報)を送信
といった感じでURIを設計しました。ちなみに「/」の時は上記URIを利用したダッシュボードを出力することにしました。
<div style="width: 20%;">
<canvas id="hum"></canvas>
</div>
<div style="width: 20%;">
<canvas id="tmp"></canvas>
</div>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.0/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.3.0/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.2.0/chart.min.js" integrity="sha512-VMsZqo0ar06BMtg0tPsdgRADvl0kDHpTbugCBBrL55KmucH6hP9zWdLIWY//OTfMnzz6xWQRxQqsUFefwHuHyg==" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@next/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
#mapcontainer { width: 100%; height: 360px; margin-top: 8px; }
</style>
<script>
addr = "get-state"
res = "";
xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4 && xhr.status==200){
res = xhr.responseText;
}
}
xhr.open("GET", addr, false);
xhr.send(null);
var jsn = JSON.parse(res);
var hum = jsn.hum;
var tmp = jsn.tmp;
var co2 = jsn.co2;
var x = jsn.x;
var ctx_hum = document.getElementById("hum").getContext('2d');
var myChart_hum = new Chart(ctx_hum, {
type: 'line',
data: {
labels: x,
datasets: [{
label: 'hum',
data: hum,
borderColor: [
'rgba(0, 0, 255, 1)'
],
borderWidth: 1
}]
},
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero:true
}
}]
}
}
});
var ctx_tmp = document.getElementById("tmp").getContext('2d');
var myChart_tmp = new Chart(ctx_tmp, {
type: 'line',
data: {
labels: x,
datasets: [{
label: 'tmp',
data: tmp,
borderColor: [
'rgba(255, 0, 0, 1)'
],
borderWidth: 1
}]
},
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero:true
}
}]
}
}
});
setInterval(async function(){
addr = "get-state"
res = "";
xhr = new XMLHttpRequest()
xhr.onreadystatechange = function(){
if (xhr.readyState == 4 && xhr.status==200){
res = xhr.responseText;
}
}
xhr.open("GET", addr, false);
xhr.send(null);
var jsn = JSON.parse(res);
var hum = jsn.hum;
var tmp = jsn.tmp;
var x = jsn.x;
myChart_hum.data.labels = x;
myChart_hum.data.datasets[0].data = hum;
myChart_hum.update('none');
myChart_tmp.data.labels = x;
myChart_tmp.data.datasets[0].data = tmp;
myChart_tmp.update('none');
}, 500);
function init(){
addr = "get-geo"
res = "";
xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4 && xhr.status==200){
res = xhr.responseText;
}
}
xhr.open("GET", addr, false);
xhr.send(null);
jsn = JSON.parse(res);
kei = jsn.coordinates[0];
ido = jsn.coordinates[1];
console.log(kei);
console.log(ido);
var map = L.map('mapcontainer',{zoomControl:false});
var mpoint = [ido, kei];
map.setView(mpoint, 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
setInterval(async function(){
addr = "get-geo"
res = "";
xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4 && xhr.status==200){
res = xhr.responseText;
}
}
xhr.open("GET", addr, false);
xhr.send(null);
jsn = JSON.parse(res);
kei = jsn.coordinates[0];
ido = jsn.coordinates[1];
var mpoint = [ido, kei];
map.setView(mpoint, 15);
L.marker(mpoint,{title:"現在地", draggable:true}).addTo(map);
}, 500);
}
</script>
<body onload="init()">
<div id="mapcontainer" style="width:600px;height:600px"></div>
</body>
でダッシュボードはこんな感じに表示されます。

ちなみに緯度経度情報において経度が28度と誤作動を起こしておりどこか分からぬ国がLeaflet上に表示されました。
まとめ
データサイエンティストは諦めてデータマイニング系で頑張ります。そのために次からは知識表現を勉強します。
スペシャルサンクス
同僚の電気の先生で人生の大先輩。ありがとうございました。
Github