見出し画像

【電子工作】HoloLens2から部屋の電気をオンオフするアプリ作ってみた

KuMA Advent Calendar 2023 20日目の記事です.
ヒーローズリーグ2023に応募するために,HoloLens2から部屋のライトを操作できるアプリを作ってみました.

ProtoPedia に書いたのですが,もう少し技術的なところを詳しく書いておこうと思います.
まずは動画を観てください.


使用したハードウェア


・HoloLens2(研究室のを休みの日に借りてきました)
・ESP32

・赤外線LEDと受信機


・もともとアパートの部屋にあったシーリングライト


使用したソフトウェア

・Arduino IDEと赤外線ライブラリ(IRremoteESP8266
・Unity2019.4.22f1
・MRTK2.5.1(音声認識のためにSpeech Input機能を使用)
uLipSync(アバターのリップシンクのために使用)
・VOICEVOX ずんだもん(アバターに喋らせるために使用)

作り方

①リモコンの赤外線を解析
 赤外線受信モジュールでリモコンの赤外線パターンを解析
 IRremoteESP8266 を使うと,簡単に解析できた

リモコンを赤外線モジュールに向けて解析している様子

この辺の記事が参考になりました.

② ①で解析した赤外線と同じパタンで赤外線LEDを光らせる
 私の部屋のライトの仕様は,
  ・ボタンが1つでつけるときも消すときも全部同じ赤外線
  ・消灯→点灯は1回の受信で行う
  ・ライトの明るさは2段階あり,点灯(明)から消灯状態にするには点灯(暗)を経由するため,2回信号を送る必要がある(500ミリ秒くらい間隔開けて信号送れば一気に消せたように見える)
  ・消灯→点灯時は500ミリ秒くらいの間隔で2回送っても1回の受信判定になる(謎便利機能.これで現在のライトの状態を判別しなくて済んだ)

 みたいな感じだったので,実際には↓のような,500ミリ秒空けて2回赤外線を送信するコードを書きました.

#include <Arduino.h>
#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <WiFi.h>
#include <WiFiUdp.h>

const int SEND_PIN = 15;
IRsend irsend(SEND_PIN);

// 赤外線データ
uint16_t rawData[67] = {
  9042, 4478,  564, 558,  564, 1676,  564, 560,  564, 560,  564, 560,  564, 560,  564, 558,  566,
  1676,  564, 1674,  566, 558,  564, 1676,  564, 1676,  564, 560,  564, 1674,  566, 1674,  568, 558,
  566, 558,  566, 1674,  566, 558,  566, 1674,  566, 1674,  568, 556,  566, 558,  566, 560,  564, 1676,
  566, 558,  566, 1674,  564, 560,  564, 558,  564, 1676,  566, 1674,  564, 1676,  564
};

const char* ssid = "SSID";
const char* password = "pass";
const int udpPort = 3334;

WiFiUDP Udp;
WiFiUDP udpBroadcast;

unsigned long previousMillis = 0;
const long interval = 5000;

unsigned long lastReceivedMillis = 0;
const long oneMinute = 60000;

bool stopBroadcast = false;

void setup() {
  irsend.begin();
  Serial.begin(115200);

  Serial.println("Connecting to Wi-Fi...");
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
  Serial.print("IP Address: ");
  Serial.println(WiFi.localIP());

  Udp.begin(udpPort);
}

void loop() {
  int packetSize = Udp.parsePacket();
  if (packetSize) {
    char incomingPacket[255];
    int len = Udp.read(incomingPacket, 255);
    incomingPacket[len] = '\0';
    Serial.printf("UDP packet contents: %s\n", incomingPacket);

    if (strcmp(incomingPacket, "Light") == 0) {
      irsend.sendRaw(rawData, sizeof(rawData) / sizeof(rawData[0]), 38); // 1回目の送信
      delay(500); // 0.5秒待つ
      irsend.sendRaw(rawData, sizeof(rawData) / sizeof(rawData[0]), 38); // 2回目の送信
      stopBroadcast = true;
      lastReceivedMillis = millis();
    }
  }

  unsigned long currentMillis = millis();
  if (currentMillis - lastReceivedMillis >= oneMinute) {
    stopBroadcast = false;
  }

  if (currentMillis - previousMillis >= interval && !stopBroadcast) {
    previousMillis = currentMillis;
    IPAddress broadcastIp(255, 255, 255, 255);
    udpBroadcast.beginPacket(broadcastIp, udpPort);
    udpBroadcast.print("ESP32_Available");
    udpBroadcast.endPacket();
    Serial.println("UDP Broadcast sent: ESP32_Available");
  }
}

UDPブロードキャストしてて,Hololens2側がそれを受信することでHoloLens2と勝手につながります(HoloLens2側のコードは後述).

HoloLens2から信号(UDPパケットで送りました)を受け取ったら,ESP32の赤外線LEDを光らせて電気をつけたり消したりします.終わり.

Hololens2でユーザがボタンを押して信号を送ってもよいのですが,せっかくなので,「電気つけて」「電気消して」などを音声認識して,アバターがライトのところまで歩いて行ってライトを押すようなアニメーションを再生しています.

アバターがライトつけてくれてる様子

ライトの位置は,あらかじめcubeを動かして指定する感じで作りました.

Unity側(HoloLens2)のコードはこんな感じ.

using UnityEngine;
using System.Collections;
using System.Net;
using System.Net.Sockets;
using System.Text;

public class VRMAvatarMover : MonoBehaviour
{
    public GameObject target;  // 目的地
    public float speed = 0.5f;  // 移動速度
    private Vector3 startPosition;  // スタート位置
    private Quaternion startRotation;  // スタート時の回転
    private Animator animator;
    private bool isReturning = false;  // 元の位置に戻るかどうか
    private bool isActivated = false;  // 動作が有効かどうか

    private UdpClient udpClient;
    private bool isRaisingHandAndReturning = false;  // 手を挙げて戻る処理中かどうかのフラグ

    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
        udpClient = new UdpClient();
    }

    // Update is called once per frame
    void Update()
    {
        if (!isActivated) return;  // 動作が無効なら何もしない

        Vector3 targetPosition = isReturning ? startPosition : new Vector3(target.transform.position.x, transform.position.y, target.transform.position.z);
        float stoppingDistance = isReturning ? 0.02f : 0.3f;
        float distanceToTarget = Vector3.Distance(transform.position, targetPosition);

        if (distanceToTarget < stoppingDistance)
        {
            animator.SetBool("isWalking", false);

            if (!isReturning)
            {
                StartCoroutine(RaiseHandAndReturn());
            }
            else
            {
                isActivated = false;
                isReturning = false;
            }
        }
        else
        {
            Vector3 direction = (targetPosition - transform.position).normalized;
            Vector3 newPosition = new Vector3(
                transform.position.x + direction.x * speed * Time.deltaTime,
                transform.position.y,
                transform.position.z + direction.z * speed * Time.deltaTime
            );
            transform.position = newPosition;

            Vector3 lookAtTarget = new Vector3(targetPosition.x, transform.position.y, targetPosition.z);
            transform.LookAt(lookAtTarget);

            animator.SetBool("isWalking", true);
            animator.SetBool("isRaisingHand", false);
        }
    }

    IEnumerator RaiseHandAndReturn()
    {
        if (isRaisingHandAndReturning)
        {
            yield break;
        }

        isRaisingHandAndReturning = true;

        animator.SetBool("isRaisingHand", true);
        yield return new WaitForSeconds(2);

        Send("Light");
        Debug.Log("send command");

        yield return new WaitForSeconds(3);

        animator.SetBool("isRaisingHand", false);
        isReturning = true;

        transform.rotation = startRotation;
        isRaisingHandAndReturning = false;
    }

    public void ActivateAndStart()
    {
        StopAllCoroutines();
        isActivated = true;
        isReturning = false;
        startPosition = transform.position;
        startRotation = transform.rotation;
        animator.SetBool("isWalking", false);
        animator.SetBool("isRaisingHand", false);
    }

    public void DelayAndStart()
    {
        StartCoroutine(DelayActivateAndStart());
    }

    private IEnumerator DelayActivateAndStart()
    {
        yield return new WaitForSeconds(2);
        ActivateAndStart();
    }

    private void Send(string message)
    {
        try
        {
            if (string.IsNullOrEmpty(GetESP32IP.esp32IpAddress))
            {
                Debug.LogError("ESP32 IP Address is not set.");
                return;
            }

            byte[] data = Encoding.UTF8.GetBytes(message);
            udpClient.Send(data, data.Length, GetESP32IP.esp32IpAddress, 3334);
        }
        catch (SocketException e)
        {
            Debug.LogError($"SocketException: {e}");
        }
    }

    private void OnDestroy()
    {
        udpClient.Close();
    }
}

アバターのアニメーションについて:アバターが180度回転してターゲットに向かって歩いて押して元の位置まで戻ってくる,みたいな処理が意外とややこしくて,ヒーローズリーグの締め切りまで時間がなくて,アバターが戻ってくるときに若干初期位置から位置と回転がずれていくみたいな状態になってしまってます(イイネイヌのはたき落とすバグみたいな感じ).


そんな感じでした.

エアコンやテレビのリモコンも同じように解析して赤外線LEDで代替できそうだったので,みんなも気が向いたら何か作ってみてください.赤外線LEDと受信モジュールが30個入りとかしか売ってなくてめちゃ余ってるので言ってくれたらあげます.

ここまで読んでいただきありがとうございました.


いただいたお金は書籍やお寿司の購入などに充てさせていただきます。