【5】DualSenseの情報をRaspberryPiに飛ばす
DualSenseの入力が取得できたので、次はRaspberryPi側に飛ばします。
できるだけ低遅延のほうが間違いなく良いので
WebSocket経由で情報を飛ばします。
目次
- 【1】Raspberry pi の、GPIOをTypescriptから操作
- 【2】DCモーターをPWMで速度制御
- 【3】サーボモーターを制御
- 【4】DualSenseをブラウザに接続
- 【5】DualSenseの情報をRaspberryPiに飛ばす
- 【6】DualSenseのチャタリング?問題対応
- 【7-1】RaspberryPiから低遅延で映像を飛ばす
- 【7-2】RaspberryPiから低遅延で映像を飛ばす
- 【8】ThreeJSでVRもどきを作成
- 【9-1】iPhoneの加速度から頭の向きをVRに反映
- 【9-2】スマホVRゴーグル向けデザインに変える
- 【10】ラジコン本体の製作
- 【11】パワーアップとバッテリー問題の解決
WebSocketクライアント作成
WebSocket自体の解説は、先人の詳しい情報が山程溢れていますので
可能な限り端折っていきます。
WebSocketでデータを送信するクライアント部分になります。
例示しているのは、かなり古いコードですが
データを送るだけならば特に問題はないので、そのまま使います。
例によって簡単なサービス化されたファイルになっています。
必要な機能としては
- 後の方で必須になるので、適当なサーバー証明でhttps化出来ていること
- Socket送信部分が、Callbackに渡せるように
以上が満たせていれば、ChatGPTにでも
「WebSocketを使ったクライアント側の機能を作って」
のように軽い感じで返ってきたコードで必要十分と思います。
本件の制作環境では、nextjsで制作を進めたので
package.jsonのスクリプトに、13.5以降で追加された
「--experimental-https」オプションを追記し、クライアント側のhttps化を行っています。
"scripts": {
"dev": "next dev --turbopack --experimental-https",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"depen
ファイル名は「socket-client.ts」にて制作しています。
コードの全文はこちらから
socket-client.tsの中身
type wsData {
job: string;
room?: string;
client?: string;
data: any;
}
type NextCallType = {
[K in 'open' | 'message' | 'close' | 'error']:
((e: Event | MessageEvent) => void) | false
}
export class SocketClient2Service {
private static instance: SocketClient2Service;
private socket: WebSocket | undefined;
private port = 3000;
private url = 'ws://localhost';
private onRoom = false;
private onEvent = false;
private Nexts: NextCallType = {
open: false,
message: false,
close: false,
error: false
};
// メッセージ受信後の処理
private next: { [key: string]: Function } = {
on: () => {
return null;
},
room: () => {
return null;
}
};
public constructor(port = 0, url = '') {
this.port = port > 0 ? port : this.port;
this.url = url !== '' ? url : this.url;
try {
this.socket = new WebSocket(this.url + ':' + this.port);
} catch (error) {
console.error(error);
}
}
public static call(
port = 3000,
url = 'http://localhost'
): SocketClient2Service {
if (!SocketClient2Service.instance) {
SocketClient2Service.instance = new SocketClient2Service(port, url);
}
return SocketClient2Service.instance;
}
public setNext(
next: (e: Event | MessageEvent) => void,
target: 'open' | 'message' | 'close' | 'error'
): SocketClient2Service {
this.Nexts[target] = next
return this
}
/**
* 待受開始
*/
public listen(): SocketClient2Service {
if (this.onEvent || this.socket === undefined) {
return this;
}
this.socket.addEventListener('open', (e: Event) => {
console.log('Open WebSocket', e);
if (this.Nexts.open !== false) {
this.Nexts.open(e);
}
});
this.socket.addEventListener('message', (m: MessageEvent) => {
const d = this.fromJson(m.data)
DataTypeHelper.call().setFile(d.data).check()
if (this.Nexts.message !== false) {
this.Nexts.message(d)
}
})
this.socket.addEventListener('close', (e: Event) => {
console.log('Close WebSocket', e)
if (this.Nexts.close !== false) {
this.Nexts.close(e)
}
})
this.socket.addEventListener('error', (e: Event) => {
console.log('Error::')
console.error(e)
if (this.Nexts.error !== false) {
this.Nexts.error(e)
}
})
this.onEvent = true
return this
}
/**
* メッセージの送信
* @param data wsData
* @returns
*/
public send(data: wsData): SocketClient2Service {
if (this.socket === undefined) return this
this.socket.send(this.toJson(data))
return this
}
public joinRoom(room = ''): SocketClient2Service {
if (this.onRoom) {
return this;
}
const _room = room === '' ? this.rand() : room;
console.log(`room: ${_room}`);
this.send({
job: 'join',
room: _room,
data: {}
});
this.onRoom = true;
return this;
}
private rand() {
const S =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const N = 16
return Array.from(Array(N))
.map(() => S[Math.floor(Math.random() * S.length)])
.join('')
}
private toJson(data: object): string {
return JSON.stringify(data)
}
private fromJson(data: string): any {
return JSON.parse(data)
}
}
DualSensの情報を投げる処理
Websocketのクライアントサービスを読み込み
- 実際の接続を行う機能
- WebHIDからの入力をサーバーに送信する
この2機能を持たせたファイルを作ります。
WebSocketサーバーと接続
作成済みのサービスから接続部分を順に呼ぶだけなので
非常に簡素です。
接続先は、RaspberryPiの設定に合わせて適時変えて下さい。
今回は
- 接続先IP : 192.168.11.10
- 接続先ポート : 8800
にて処理を書いています。
接続部分で1秒待ちが入っているのは
本来は接続イベントをキャッチした後に続く処理を明記しますが
書き換えが面倒であったため、1秒待てばほぼ100%接続が終わってるであろうと
1秒間のスリープを挟んでいます。
// インスタンス作成
ws = SocketClient.call(
8800, // RaspberryPi側のWebSocketのポート
'https://192.168.11.10', // RaspberryPi側のIP
)
// サーバーと通信開始
ws.listen()
await sleep(1000)
// 部屋に入る
ws.joinRoom()
ですので、本来はWebSocketのサービス側で
処理できていないとだめな部分ですが
今回はさして重要ではありませんのでスルーします。
WebSocketサーバーに投げる
サーバーと接続し、送信の準備が出来ると
WebHIDからの入力イベントを処理したコールバック関数から
ボタンのマッピングを行った変数を受け取り
そのまま投げる部分が要りますので
下記のように
JSON文字列に変換して投げるだけ
の処理を作ります。
ws.send({
job: 'other',
data: JSON.stringify({
task: 'ctrl',
button: {
up: state.buttons.dPadUp,
down: state.buttons.dPadDown,
left: state.buttons.dPadLeft,
right: state.buttons.dPadRight,
left_stick_x: state.axes.leftStickX,
left_stick_y: state.axes.leftStickY,
left_stick: state.buttons.leftStick,
right_stick_x: state.axes.rightStickX,
right_stick_y: state.axes.rightStickY,
right_stick: state.buttons.rightStick,
triangle: state.buttons.triangle,
circle: state.buttons.circle,
cross: state.buttons.cross,
square: state.buttons.square,
l1: state.buttons.l1,
l2: state.buttons.l2,
l3: state.buttons.l3,
r1: state.buttons.r1,
r2: state.buttons.r2,
r3: state.buttons.r3,
left_axes: convertRange(state.axes.l2),
right_axes: convertRange(state.axes.r2),
start: state.buttons.start,
select: state.buttons.select,
}
})
})
コントローラーのボタンOn/Offはそのまま送って問題無いのですが
アナログ部分、スピード調整を行うトリガーの入力は
0.0~1.0の範囲となり、モーターコントローラーの0~255と異なります。
ですので、アナログ入力の数値範囲を変換する関数「convertRange」を
トリガーの入力にだけ挟んでいます。
// DCモーターのパワーを、0~255の間に変換する
export const convertRange = (value: number): number => {
if (value < 0 || value > 1) {
throw new Error('Value must be between 0.0 and 1.0');
}
return Math.round(value * 254) + 1;
}
以下、サンプルの全文です。
このファイルを「socket.helper.ts」というファイル名で保存し、使います。
socket.helper.tsのコード
import { SocketClient } from './socket_client'
import { ControlType } from '@/src/definisions'
import { DualSense } from './webhid_ds'
let ws: SocketClient | null = null
// WebSocketサーバーに接続
export const socketConnect = async (): Promise<void> => {
// インスタンス作成
ws = SocketClient.call(
8800, // RaspberryPi側のWebSocketのポート
'https://192.168.11.10', // RaspberryPi側のIP
)
// サーバーと通信開始
ws.listen()
await sleep(1000)
// 部屋に入る
ws.joinRoom()
}
// WebHIDのサービスに渡す、コールバック関数
export const buttonSend = async (
state: DualSense['state'],
data: DataView
): Promise<void> => {
if (!ws) {
console.log('ws is not connected')
return
}
ws.send({
job: 'other',
data: JSON.stringify({
task: 'ctrl',
button: {
up: state.buttons.dPadUp,
down: state.buttons.dPadDown,
left: state.buttons.dPadLeft,
right: state.buttons.dPadRight,
left_stick_x: state.axes.leftStickX,
left_stick_y: state.axes.leftStickY,
left_stick: state.buttons.leftStick,
right_stick_x: state.axes.rightStickX,
right_stick_y: state.axes.rightStickY,
right_stick: state.buttons.rightStick,
triangle: state.buttons.triangle,
circle: state.buttons.circle,
cross: state.buttons.cross,
square: state.buttons.square,
l1: state.buttons.l1,
l2: state.buttons.l2,
l3: state.buttons.l3,
r1: state.buttons.r1,
r2: state.buttons.r2,
r3: state.buttons.r3,
left_axes: convertRange(state.axes.l2),
right_axes: convertRange(state.axes.r2),
start: state.buttons.start,
select: state.buttons.select,
}
})
})
}
// DCモーターのパワーを、0~255の間に変換する
export const convertRange = (value: number): number => {
if (value < 0 || value > 1) {
throw new Error('Value must be between 0.0 and 1.0');
}
return Math.round(value * 254) + 1;
}
// sleep
const sleep = (msec: number) => new Promise(resolve => setTimeout(resolve, msec))
DualSenseの情報をサーバーに投げる
- WebSocketのクライアント
- サーバーへの接続と、コントローラーの入力の送信
- DualSenseと接続し、入力を受ける
前回と今回で、必要な機能が揃ったと思います。
Bluetooth接続されたDualSenseを、WebHID経由で操作情報を受け取り、WebSocket経由でサーバーに投げる
という一連の処理を続けて行います。
BluetoothとDualSenseの接続は
WebHIDの呼び出し時に、機器の選択が1クッション入ります。
この段階で繋いでも問題ありませんので、気がついたタイミングで繋いでおいてください。
機能は既に出来てますので
順に呼ぶだけで機能します。
今の段階では、サーバー側が無いのでエラーになります。
import { ReturnSuccess, ReturnError } from "@/src/definisions"
import { ButtonStatus, init } from "@/src/_lib/bluetooth/ds4.service"
import { socketConnect, buttonSend } from "@/src/domain/Control/helper/socket.helper"
import { DualSenseStateType } from "../../../_lib/bluetooth/_helper/webhid_ds"
const BS = ButtonStatus
export const controlDispatch = async (
): Promise<ReturnSuccess<string> | ReturnError> => {
// WebSocket接続
await socketConnect()
// DualSense接続
initDS4()
// WebHID情報が更新されるたびにWebSocketで送信
init(buttonSend)
return {
status: true,
message: ''
}
}
実装の際は、ブラウザのボタンイベント等に紐づけし
「controlDispatch」を呼び出してもらうと
クライアント側の処理が順に行われます。
WebSocketサーバー作成
次にサーバー側に入ります。
クライアント側と同じく、かなり前に作ったサービスをそのまま利用しています。
nodejsの問題でGPIOの制御側と同じJavascriptに変更しています
そして、先人達の知恵が大量に見つかりますので
WebSocketの実装部分の説明は割愛します。
このサービスは「socket-server.js」で保存しています。
コードの全文はこちらから
socket-server.jsの中身
const ws = require('ws')
const fs = require('fs')
const https = require('https')
let certs = {
key: '',
cert: '',
}
let server = null
let port = 8800
//const app = express()
let _server = null
let jobStack = {
cast : [],
other : [],
}
// メッセージ受信後の処理
const next = {
cast : () => {return null},
other : () => {return false},
}
/**
* 証明書を設定
* @param key string
* @param cert string
* @returns SocketServer2Service
*/
function setCerts(key, cert) {
certs.key = fs.readFileSync(key)
certs.cert = fs.readFileSync(cert)
}
/**
* WebSocketの待受ポート設定
* @param port number
* @returns SocketServer2Service
*/
function setPort(_port) {
_port = (typeof(_port) === 'number' ) ? _port : port
}
/**
* メッセージ受信後の処理を設定
* クライアントから送信された[message]を
* そのまま受け取り処理する関数を任意で追加出来る
* ※上書きではなく処理の追加なので注意
*
* @param next Function
* @param target string Casts
* @returns
*/
function setNext(
_next = (data) => {console.log(data)},
target
) {
try {
_next('a')
next[target] = _next
} catch (error) {
}
}
/**
* 接続中の全クライアントに送信
* @param data any(文字列 or 配列 or オブジェクト)
* @returns ScoketServer2Service
*/
function sendAllClient(data) {
server.clients.forEach((client) => {
send(client, 'cast', data)
})
}
function otherRequest(data) {
next.other(data)
}
/**
* websocketサーバーを起動
* @returns
*/
function start() {
if (certs.key !== '' && certs.cert !== '') {
_server = https.createServer({
key: certs.key,
cert: certs.cert,
})
server = new ws.Server({ server : _server })
console.log(`
[Start SSL Socket Server] Listen Port :: ${port}
`);
} else {
server = new ws.Server({ port : port})
console.log(`
[Start Socket Server] Listen Port :: ${port}
`);
}
// サーバーアクション設定
action()
if (_server !== null) {
_server.listen(port)
}
}
/**
* 受信時の動作を登録
* @returns
*/
function action() {
server.on('connection', (s) => {
// IDを付与
s.ipAddress = s._socket.remoteAddress.replace(/^.*:/g,'')
s.port = s._socket.remotePort
s.unique = buildId()
s.id = `${s.ipAddress}:${s.port}:${s.unique}`
// 生成されたIDを返す
s.send(JSON.stringify({
job : 'connection',
id : s.id,
}))
// 待受開始
s.on(
'message',
async (message) => {
const m = fromJson(message)
await lisnHub(m, s)
executeJob()
}
)
})
}
/**
* job毎に処理を振り分け
* @param m any 受信データ
* @param s any 接続ClientのSocket
* @returns Promise<SocketServer2Service>
*/
async function lisnHub(m, s) {
if (m.job === 'cast') {
const job = (next.cast() !== null)
? next.cast(m) : {to: 'cast', data: m.data}
addJob(job, 'cast')
}
if (m.job === 'other') {
const job = {to: 'other', data: m.data}
addJob(job, 'other')
}
}
/**
* Jobスタックにデータを追加
* @param job {to: string, data: any} 送信先と送信データ
* @param send Casts 送信方式、全配信、部屋配信、指定配信
* @returns SocketServer2Service
*/
function addJob(
job,
send
) {
jobStack[send].push(job);
}
/**
* スタックされたジョブを実行
* @returns SocketServer2Service
*/
function executeJob() {
for (const key in jobStack) {
if (Object.prototype.hasOwnProperty.call(jobStack, key)) {
if (jobStack[key].length > 0) {
jobStack[key].map((job) => {
if (key === 'cast') {
sendAllClient(job.data);
} else if (key === 'other') {
otherRequest(fromJson(job.data));
}
})
jobStack[key] = [];
}
}
}
}
/**
* クライアントに送信
* @param client any WebSocketオブジェクト
* @param job string 送信処理種別
* @param data any 送信データの中身(JSON文字列)
* @returns SocketServer2Service
*/
function send(client, job, data) {
client.send(toJson({
job : job,
data : data
}))
}
/**
* ClientIDを生成
* @returns string ランダム生成されたClinetID
*/
function buildId() {
return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
}
function toJson(data) {
return JSON.stringify(data);
}
function fromJson(data) {
return JSON.parse(data);
}
module.exports = {
setCerts,
setPort,
setNext,
sendAllClient,
start,
action,
lisnHub,
addJob,
executeJob,
send,
buildId,
toJson,
fromJson
};
サーバー証明書
使用するサーバー証明書は
openssl等を使用し、適時作成するか、手元の証明書を活用ください。
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr
オレオレ証明書の場合
不明なサーバー証明を許可する必要があるので
WebSocketサーバー起動後、該当のポートにブラウザでアクセスする必要があります。
接続クライアントが実質1台のみ
通信内容も定型で、それ以外を考える必要も無いので
かなり簡素に
データが来たら、GPIOの制御関数に渡す
のみ行います。
以下の内容を記述した、「test.js」を保存します。
const socket = require('./socket_server')
const control = require('./control.helper')
const keys = {
key: './keys/key.pem',
cert: './keys/cert.pem'
}
function setup() {
socket.setCerts(keys.key, keys.cert)
control.setup()
socket.setNext(
async (data) => {
if (data.task === 'ctrl') {
control.control(data.button)
}
},
'other'
)
return this
}
const listen_start = () => {
socket.start()
}
setup()
listen_start()
操作情報でGPIOを操作する
ボタン数は非常に多くありますが
とりあえず、ハンドルとアクセル(前進・後退)のみあればいいので
入力キー | 操作 |
---|---|
左キー | ハンドルを左に |
右キー | ハンドルを右に |
右トリガー | 前進 |
左トリガー | 後退 |
以上の入力を割り振ります。
後々、ブースト機能や、ライト点灯、ミスト噴射、何かしら射出等、付けても面白くなります。
モーターのパワー、ハンドル角の制御変数と合わせて
このようなオブジェクトにまとめています。
const MotorPorts = {
MOROT1_IN: 19,
MOROT1_OUT: 26,
MORTOR_PWM: 12,
MORTOR_POWER: 1,
HANDLE: 17,
INITIAL_ANGLE: 850,
INITIAL: false
}
初期化処理は、GPIOのサンプルと同じで
DCモーターと、サーボの処理が一緒になっただけです。
複数回呼ばれるとよろしく無いので
実行済みフラグを入れています。
if (MotorPorts.INITIAL) {
return
}
gpio.addPort({port: MotorPorts.MOROT1_IN, mode: 'out'})
gpio.addPort({port: MotorPorts.MOROT1_OUT, mode: 'out'})
gpio.addPort({port: MotorPorts.MORTOR_PWM, mode: 'out'})
gpio.addPort({port: MotorPorts.HANDLE, mode: 'out'})
await gpio.initGpio()
console.log(gpio.write(MotorPorts.MOROT1_IN, 0))
console.log(gpio.write(MotorPorts.MOROT1_OUT, 0))
console.log(gpio.pwmWrite(MotorPorts.MORTOR_PWM, MotorPorts.MORTOR_POWER))
MotorPorts.INITIAL = true
gpio.searvoWrite(MotorPorts.HANDLE, MotorPorts.INITIAL_ANGLE)
websocketサーバーのところで実際に呼ばれていた
入力情報を受け取る部分です。
見たまんまですが
押されたボタンに合わせて紐づけされたGPIOに出力を送ります。
const control = (button) => {
ButtonMap = button
if (button.right_axes > 1) {
MotorPorts.MORTOR_POWER = Number(button.right_axes)
console.log(gpio.write(MotorPorts.MOROT1_IN, 0))
console.log(gpio.write(MotorPorts.MOROT1_OUT, 1))
console.log(gpio.pwmWrite(MotorPorts.MORTOR_PWM, MotorPorts.MORTOR_POWER))
}
if (button.left_axes > 1 && button.right_axes === 1) {
MotorPorts.MORTOR_POWER = Number(button.left_axes)
console.log(gpio.write(MotorPorts.MOROT1_IN, 1))
console.log(gpio.write(MotorPorts.MOROT1_OUT, 0))
console.log(gpio.pwmWrite(MotorPorts.MORTOR_PWM, MotorPorts.MORTOR_POWER))
}
if (button.left) {
console.log(gpio.searvoWrite(MotorPorts.HANDLE, 1150))
}
if (button.right) {
console.log(gpio.searvoWrite(MotorPorts.HANDLE, 550))
}
}
この内容をまとめて、「control.helper.js」で保存しています。
コードの全文は以下
control.helper.jsの中身
const gpio = require('./gpio.service')
let ButtonMap = {
up: false,
down: false,
left: false,
right: false,
left_stick_x: 0,
left_stick_y: 0,
left_stick: false,
right_stick_x: 0,
right_stick_y: 0,
right_stick: false,
triangle: false,
circle: false,
cross: false,
square: false,
l1: false,
l2: false,
l3: false,
r1: false,
r2: false,
r3: false,
left_axes: 1,
right_axes: 1,
start: false,
select: false,
}
const MotorPorts = {
MOROT1_IN: 19,
MOROT1_OUT: 26,
MORTOR_PWM: 12,
MORTOR_POWER: 1,
HANDLE: 17,
INITIAL_ANGLE: 850,
INITIAL: false
}
const setup = async () =>{
if (MotorPorts.INITIAL) {
return
}
gpio.addPort({port: MotorPorts.MOROT1_IN, mode: 'out'})
gpio.addPort({port: MotorPorts.MOROT1_OUT, mode: 'out'})
gpio.addPort({port: MotorPorts.MORTOR_PWM, mode: 'out'})
gpio.addPort({port: MotorPorts.HANDLE, mode: 'out'})
await gpio.initGpio()
console.log(gpio.write(MotorPorts.MOROT1_IN, 0))
console.log(gpio.write(MotorPorts.MOROT1_OUT, 0))
console.log(gpio.pwmWrite(MotorPorts.MORTOR_PWM, MotorPorts.MORTOR_POWER))
MotorPorts.INITIAL = true
gpio.searvoWrite(MotorPorts.HANDLE, MotorPorts.INITIAL_ANGLE)
}
const control = (button) => {
ButtonMap = button
if (button.right_axes > 1) {
MotorPorts.MORTOR_POWER = Number(button.right_axes)
console.log(gpio.write(MotorPorts.MOROT1_IN, 0))
console.log(gpio.write(MotorPorts.MOROT1_OUT, 1))
console.log(gpio.pwmWrite(MotorPorts.MORTOR_PWM, MotorPorts.MORTOR_POWER))
}
if (button.left_axes > 1 && button.right_axes === 1) {
MotorPorts.MORTOR_POWER = Number(button.left_axes)
console.log(gpio.write(MotorPorts.MOROT1_IN, 1))
console.log(gpio.write(MotorPorts.MOROT1_OUT, 0))
console.log(gpio.pwmWrite(MotorPorts.MORTOR_PWM, MotorPorts.MORTOR_POWER))
}
if (button.left) {
console.log(gpio.searvoWrite(MotorPorts.HANDLE, 1150))
}
if (button.right) {
console.log(gpio.searvoWrite(MotorPorts.HANDLE, 550))
}
}
module.exports = {
setup,
control
}
試験
完成した内容を、下記コマンドで実行し
待受状態にした後
sudo node test.js
ブラウザから接続処理を開始すると
コントローラーの入力に合わせて、各モーターが動きますが
問題が起こります。
DCモーターは動きっぱなしで
サーボモーターはセンター位置に戻りません
次回はこの問題を修正します。
UZAYA
Uzayaでは、多分仕事を求めています。
何かの役に立ちそうでしたら、是非お知らせを。