みかづきブログ・カスタム

基本的にはちょちょいのほいです。

AirPods → iPhoneアプリ → Express → python-shell → pymycobot → myCobotと繋いでいって、AirPodsの回転角とmyCobotの姿勢と同期する 🤖

AirPods → iPhoneアプリ → Express → python-shell → pymycobot → myCobotと繋いでいって、AirPodsの回転角とmyCobotの姿勢を同期させました。

需要があるかは謎ですが、ざっくりとしたソースコードを記載します。

iOSアプリ

以前つくったこちらのアプリをベースにしています。

blog.kimizuka.org

変更点だけを記載しておくと、onDeviceMotionUpdatesの中にWebサーバへのポストの処理を入れています。
また、アップデートがあるたびにPOSTリクエストを送ってしまうとサーバが大変なので、最低でも500ms間を開けています。

App.tsx(抜粋)

useEffect(() => {
  const delay = 500;
  const handleDeviceMotionUpdates = onDeviceMotionUpdates((data) => {
    // 前回から500ms未満の場合はリターン
    if (Date.now() - lastUpdateTimeRef.current < delay) {
      return;
    }

    // Webサーバにセンサ値をPOSTする
    axios.post(String(process.env.API_URL), { 
      pitch: data.attitude.pitchDeg || 0,
      roll: data.attitude.rollDeg || 0,
      yaw: data.attitude.yawDeg || 0
    }).then(() => {
      lastUpdateTimeRef.current = Date.now();
    }).catch((err) => {
      console.error(err);
      lastUpdateTimeRef.current = Date.now();
    });

    setPitch(data.attitude.pitch);
    setPitchDeg(data.attitude.pitchDeg);
    setRoll(data.attitude.roll);
    setRollDeg(data.attitude.rollDeg);
    setYaw(data.attitude.yaw);
    setYawDeg(data.attitude.yawDeg);
    setGravityX(data.gravity.x);
    setGravityY(data.gravity.y);
    setGravityZ(data.gravity.z);
    setRotationRateX(data.rotationRate.x);
    setRotationRateY(data.rotationRate.y);
    setRotationRateZ(data.rotationRate.z);
    setUserAccelerationX(data.userAcceleration.x);
    setUserAccelerationY(data.userAcceleration.y);
    setUserAccelerationZ(data.userAcceleration.z);
  });

  return () => {
    handleDeviceMotionUpdates.remove();
  };
}, []);

POSTにはaxiosを使いました。

なので、

import axios from 'axios';

と、モジュールのインポートも追加する必要があります。

結果として、

App.tsx(全文)

import axios from 'axios'; // 簡単にPOSTするために追加
import React, {
  useEffect,
  useRef, // 500ms間を開けるために追加
  useState,
} from 'react';
import {Button, SafeAreaView, StyleSheet, Text} from 'react-native';
import {
  requestPermission,
  onDeviceMotionUpdates,
  startListenDeviceMotionUpdates,
  stopDeviceMotionUpdates,
} from 'react-native-headphone-motion';

const API_URL = 'http://localhost:3000'; // POSTするURLを入れる

export default function App() {
  const lastUpdateTimeRef = useRef<number>(0); // 最後の更新時刻を保持するために追加
  const [pitch, setPitch] = useState(0);
  const [pitchDeg, setPitchDeg] = useState(0);
  const [roll, setRoll] = useState(0);
  const [rollDeg, setRollDeg] = useState(0);
  const [yaw, setYaw] = useState(0);
  const [yawDeg, setYawDeg] = useState(0);
  const [gravityX, setGravityX] = useState(0);
  const [gravityY, setGravityY] = useState(0);
  const [gravityZ, setGravityZ] = useState(0);
  const [rotationRateX, setRotationRateX] = useState(0);
  const [rotationRateY, setRotationRateY] = useState(0);
  const [rotationRateZ, setRotationRateZ] = useState(0);
  const [userAccelerationX, setUserAccelerationX] = useState(0);
  const [userAccelerationY, setUserAccelerationY] = useState(0);
  const [userAccelerationZ, setUserAccelerationZ] = useState(0);

  useEffect(() => {
    const delay = 500; // 更新間隔を変数にいれておく
    const handleDeviceMotionUpdates = onDeviceMotionUpdates(data => {
      if (Date.now() - lastUpdateTimeRef.current < delay) {
        // 更新間隔に満たない場合にリターン
        return;
      }

      // Webサーバにセンサ値をPOST
      // 成功しても失敗してもlastUpdateTimeRefを更新
      // なんとなくawaitは使用せず
      axios
        .post(String(API_URL), {
          pitch: data.attitude.pitchDeg || 0,
          roll: data.attitude.rollDeg || 0,
          yaw: data.attitude.yawDeg || 0,
        })
        .then(() => {
          lastUpdateTimeRef.current = Date.now();
        })
        .catch(err => {
          console.error(err);
          lastUpdateTimeRef.current = Date.now();
        });

      setPitch(data.attitude.pitch);
      setPitchDeg(data.attitude.pitchDeg);
      setRoll(data.attitude.roll);
      setRollDeg(data.attitude.rollDeg);
      setYaw(data.attitude.yaw);
      setYawDeg(data.attitude.yawDeg);
      setGravityX(data.gravity.x);
      setGravityY(data.gravity.y);
      setGravityZ(data.gravity.z);
      setRotationRateX(data.rotationRate.x);
      setRotationRateY(data.rotationRate.y);
      setRotationRateZ(data.rotationRate.z);
      setUserAccelerationX(data.userAcceleration.x);
      setUserAccelerationY(data.userAcceleration.y);
      setUserAccelerationZ(data.userAcceleration.z);
    });

    return () => {
      handleDeviceMotionUpdates.remove();
    };
  }, []);

  return (
    <SafeAreaView style={styles.container}>
      <Button
        title={'requestPermission'}
        onPress={async () => {
          await requestPermission();
        }}
      />
      <Button
        title={'startListenDeviceMotionUpdates'}
        onPress={async () => {
          await startListenDeviceMotionUpdates();
        }}
      />
      <Button
        title={'stopDeviceMotionUpdates'}
        onPress={async () => {
          await stopDeviceMotionUpdates();
        }}
      />
      <Text>{lastUpdateTimeRef.current}</Text>
      <Text>{`pitch: ${pitch}`}</Text>
      <Text>{`pitchDeg: ${pitchDeg}`}</Text>
      <Text>{`roll: ${roll}`}</Text>
      <Text>{`rollDeg: ${rollDeg}`}</Text>
      <Text>{`yaw: ${yaw}`}</Text>
      <Text>{`yawDeg: ${yawDeg}`}</Text>
      <Text>{`gravityX: ${gravityX}`}</Text>
      <Text>{`gravityY: ${gravityY}`}</Text>
      <Text>{`gravityZ: ${gravityZ}`}</Text>
      <Text>{`rotationRateX: ${rotationRateX}`}</Text>
      <Text>{`rotationRateY: ${rotationRateY}`}</Text>
      <Text>{`rotationRateZ: ${rotationRateZ}`}</Text>
      <Text>{`userAccelerationX: ${userAccelerationX}`}</Text>
      <Text>{`userAccelerationY: ${userAccelerationY}`}</Text>
      <Text>{`userAccelerationZ: ${userAccelerationZ}`}</Text>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'white',
  },
});

というコードになりました。
本当は、API_URLはアプリ上から指定できた方が便利なのですが、スピード重視で実装してしまいました。

ウェブサーバ

ウェブサーバはMacにローカルサーバを立てました。
まず、myCobotを動かすために、こちらの設定をします。

blog.kimizuka.org

本当はpythonでウェブサーバをつくれればスムーズだと思うのですが、僕のスキルセットではNode.jsでつくるのが一番早いので、Expressを使ってささっとサーバをつくります。
myCobotとの通信はpythonで行うので、その部分はpython-shellをつかって行うことにしました。

app.js

require('dotenv').config(); // 外部からmyCobotのポートを渡すために使用
const express = require('express');
const { PythonShell } = require('python-shell'); // myCobotとの通信用
const app = express();
const http = require('http').Server(app);

const duration = 100; // アプリ側のdelay(500ms)よりも小さくしないと破綻する

app.use(express.json());
app.post('/', (req, res) => {
  try {
    const angles = [0, 0, 0, 0, 0, 0];

    // myCobotの関節はhttps://www.elephantrobotics.com/wp-content/uploads/2021/03/myCobot-User-Mannul-EN-V20210318.pdfの13ページを参照
    // 配列には6個の関節を下から順番に収納する
    // 関節ごとに可動域が決まっているので超えないように制限する
    angles[0] = Math.max(-90, Math.min(req.body.yaw || 0, 90)); // J1
    angles[3] = Math.max(-90, Math.min(req.body.pitch || 0, 90)); // J4
    angles[5] = Math.max(-175, Math.min(req.body.roll || 0, 175)); // J6

    // USBで接続されているmyCobotをpythonで指示を出す
    PythonShell.runString(
      `from pymycobot.mycobot import MyCobot; MyCobot('${ process.env.MY_COBOT_PORT }').send_angles([${ angles }], ${ duration })`,
      null,
      (err) => err && console.error(err)
    );
  } catch (err) {
    console.error(err);
  }
  res.send(200);
});

try {
  const angles = [0, 0, 0, 0, 0, 0];

  // 起動時に姿勢をリセットする
  PythonShell.runString(
    `from pymycobot.mycobot import MyCobot; MyCobot('${ process.env.MY_COBOT_PORT }').send_angles([${ angles }], ${ duration })`,
    null,
    (err) => err && console.error(err)
  );
} catch(err) {
  console.error(err);
}

http.listen(3000, '0.0.0.0');

という感じです。
PythonShellでpymycobotを実行するので、app.jsの同階層にpymycobotpymycobotディレクトリを設置する必要があります。

github.com


準備ができたら、PCとmyCobotを繋いで、

node app.js

を実行すればウェブサーバが起動し、POSTリクエストで受け取った、pitch、roll、yawをmyCobotに伝達します。
今回はiPhoneアプリからAirPodsのセンサ値をPOSTしていますが、POST元はなんでも大丈夫なので、こういうサーバをひとつ作っておくと、どこかで活用できる気がしています。

DEMO

リポジトリ

github.com