M5StackでWeb呼鈴を作る

# はじめに
弊社では入り口前にiPhoneを設置して、来訪者に通話アプリで呼び出してもらうということをやっていたのですが、最近どうも通話アプリの調子が悪く困っていました。
別に通話できる必要はないので、M5Stackを使ってWeb呼び鈴を作ってみました。

# M5Stack側の仕様

Webサーバー機能を利用し、下記の静的ファイル配信やAPIを用意しました。

- /index.html : CSS/JSを含む子機側iPhone用のHTMLファイル
- /sound.mp3 : 子機側で鳴らす呼出音のMP3ファイル
- /status : 親機が呼出中であるかの状態を確認するAPIです
- /activate : 子機から親機を呼び出すためのAPIです
- /disactivate : 子機からの親機呼び出しを止めるAPIです

静的ファイルはSDカードに配置し、M5Stack起動時に読みこんで配信しています。
M5Stackのディスプレイには、割り当てられているIPアドレスを表示させます。
また、呼出中はその旨の表示をするようにしました。
※M5Stack側でも呼出音を鳴らそうとしたのですが、アドベントカレンダー投稿日に間に合いませんでした…。

# 動作中の様子

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">M5StackでWeb呼鈴を作る <a href="https://t.co/XXgnLClDix">pic.twitter.com/XXgnLClDix</a></p>&mdash; CIB-MC🏳️‍🌈 (@CIB_MC) <a href="https://twitter.com/CIB_MC/status/1605559646508441601?ref_src=twsrc%5Etfw">December 21, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 実際のコード

## Aruduino

<details><summary>長いので折りたたんでいます</summary><div>

``` cpp:ring_bell.ino
/*
Copyright (c) 2022, CIB-MC
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, 
  this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, 
  this list of conditions and the following disclaimer in the documentation 
  and/or other materials provided with the distribution.
* Neither the name of the <organization> nor the names of its contributors 
  may be used to endorse or promote products derived from this software 
  without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
#include <M5Stack.h>
#include <WiFi.h>
#include <WebServer.h>

const char* ssid       = "xxxxxxxxxx";
const char* password   = "xxxxxxxxxx";

TFT_eSprite sprite = TFT_eSprite(&M5.Lcd);
WebServer server(80);

size_t size_f = 0;
uint8_t* mp3_bin;

String html = "";
String mp3 = "";
String ip_addr = "";

bool confirmed = true;

void setup() {
  M5.begin();
  Serial.begin(115200);
  sprite.setColorDepth(8);
  sprite.createSprite(320,240);
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setCursor(10, 25, 2);
  M5.Lcd.println("WiFi connecting");
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
      delay(5000);
      Serial.print(".");
  }
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setCursor(10, 25, 2);
  M5.Lcd.println("WiFi CONNECTED");
  ip_addr = WiFi.localIP().toString();
  M5.Lcd.println(ip_addr);

  M5.Lcd.println("Loading html");
  File f_html = SD.open("/index.html");
  if (f_html) {
    while(f_html.available()) {
      String readstring = f_html.readString();
      html = html + readstring;
    }
  }
  f_html.close();
  M5.Lcd.println("done");

  M5.Lcd.println("Loading mp3");
  File f_mp3 = SD.open("/sound.mp3");
  if (f_mp3) {
    while(f_mp3.available()) {
      String readstring = f_mp3.readString();
      mp3 = mp3 + readstring;
    }
  }
  f_mp3.close();
  M5.Lcd.println("done");
  

  server.onNotFound(handleNotFound);
  server.on("/", handleRoot);
  server.on("/sound.mp3", handleMp3);
  server.on("/status", handleStatus);
  server.on("/activate", handleActivate);
  server.on("/disactivate", handleDisactivate);
  server.begin();
}

void loop() {
  M5.update();
  server.handleClient();

  sprite.fillScreen(BLACK);
  sprite.setTextDatum(1);
  sprite.setTextSize(2);
  sprite.drawCentreString(ip_addr, 160, 150, 2);
  if (!confirmed) {
    sprite.setTextSize(5);
    sprite.drawCentreString("CALLING", 160, 45, 2);
  }
  if(M5.BtnB.wasPressed()) {
    confirmed = true;
  }
  sprite.pushSprite(0, 0);
  delay(50);
}

void handleNotFound() {
 server.send(404, "text/plain", "File not found");
}

void handleRoot() {
 server.send(200, "text/html", html);
}

void handleMp3() {
 Serial.println(mp3);
 server.send(200, "audio/mpeg", mp3);
}

void handleStatus() {
 server.send(200, "text/plain", confirmed ? "confirmed" : "calling");
}

void handleActivate() {
  confirmed = false;
  server.send(200, "text/plain", "ok");
}

void handleDisactivate() {
 confirmed = true;
 server.send(200, "text/plain", "ok");
}

```
</div></details>

## HTML

<details><summary>長いので折りたたんでいます</summary><div>

```html:index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name=”viewport” content=”width=device-width,initial-scale=1,user-scalable=no″>
    <title>総合受付</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            position: relative;
            font-family: sans-serif;
        }
        main {
            overflow: hidden;
            width: 100vw;
            height: 100vh;
            display: flex;
            flex-direction: column;
            justify-content: space-between;
            align-items: center;
        }
        main > * {
            overflow: hidden;
            display: flex;
            justify-content: center;
            align-items: center;
        }
        h1 {
            width: 100vw;
            height:10vh;
            font-size: 5vh;
            text-align: center;
            color: white;
            background-color: #005700;
        }
        .message {
            width: 100vw;
            height: 20vh;
            border-color: #005700;
            border-style: dotted;
            border-width: 1vh 0;
            font-size: 2vh;
            text-align:center;
        }
        #call {
            width: 40vh;
            height: 40vh;
            font-weight: bold;
            border-width: 2vh;
            border-radius: 20vh;
        }
        .activate {
            font-size: 25vh;
            color: white;
            background-color: #005700;
            border-color:#004700;
        }
        .activate::before {
            content: "♪";
        }
        .activate:active,
        .activate:hover {
            background-color: #006700;
            border-color:#006700;
        }
        
        .disactivate {
            font-size: 8vh;
            color: white;
            background-color: gray;
            border-color: lightgray;
            animation: calling_animation 1s infinite;
        }
        .disactivate::before {
            content: "呼出中";
        }
        .disactivate:active,
        .disactivate:hover {
            background-color: lightgray;
            border-color: whitesmoke;
        }
        
        footer {
            width: 100vw;
            height: 3vh;
            font-size: 2vh;
            text-align: center;
            color: white;
            background-color: #005700;
        }
        @keyframes calling_animation {
            0% {transform: translate(0, 0);}
            5% {transform: translate(-1vw, -0);}
            10% {transform: translate(1vw, 0);}
            15% {transform: translate(-1vw, -0);}
            20% {transform: translate(1vw, 0);}
            25% {transform: translate(-1vw, -0);}
            30% {transform: translate(1vw, -0);}
            35% {transform: translate(-1vw, -0);}
            40% {transform: translate(1vw, -0);}
            45% {transform: translate(0, 0);}
            100% {transform: translate(0, 0);}
        }
        #modal {
            visibility: visible;
            position: fixed;
            z-index: 100;
            left:0;
            top:0;
            overflow: hidden;
            display: flex;
            justify-content: center;
            align-items: center;
            width: 100vw;
            height: 100vh;
            font-size: 3.5vh;
            background-color: rgba(0,0,0,0.7);
        }
        .hidden {
            visibility: hidden !important;
        }
        #modal > * {
            text-align: center;
            opacity: 1;
            color: black;
            background-color: white;
            width: 80vw;
            border-radius: 3vh;
            padding: 2vh 1vh;
        }
        .round_icon {
            display: inline-block;
            width: 10vh;
            height: 10vh;
            font-weight: bold;
            font-size: 7vh;
            border-radius: 5vh;
            overflow: hidden;
            color: white;
            background-color: orange;
            text-align: center;
            animation: calling_animation 1s infinite;
        }
    </style>
</head>
<body>
<main>
    <h1><p>総合受付</p></h1>
    <button id="call" class="activate" type="button"><p></p></button>
    <div class="message"><p>本日はお越しいただきまして<br>誠にありがとうございます<br>音符ボタンにて担当の者をお呼びください</p></div>
    <footer><p>(C) CIB-MC</p></footer>
</main>
<div id=modal><p><span class="round_icon">!</span><br>親機に接続中です<br>しばらくお待ち下さい</p></div>
<script>
let sound = new Audio("/sound.mp3");
let confirmed = false;
let calling = false;
let sound_interval_id = undefined;
let status_interval_id = undefined;
let modal = document.getElementById('modal');
let button = document.getElementById('call');
button.onclick = () => {
    let ts = new Date().getTime();
    if (calling) {
        let req = new XMLHttpRequest();
        req.onreadystatechange = () => {
            if (req.readyState == 4) {
                if ((req.status == 200) && (req.responseText == "ok")) {
                    deactivate();
                }
            }
        }
        req.ontimeout = () => {
            deactivate();
            modal.className = "";
        }
        req.open('GET', '/disactivate?' + ts, true);
        req.send(null);
    } else {
        let req = new XMLHttpRequest();
        req.onreadystatechange = () => {
            if (req.readyState == 4) {
                if ((req.status == 200) && (req.responseText == "ok")) {
                    button.className = "disactivate";
                    sound_interval_id = repeat_sound();
                    calling = true;
                }
            }
        }
        req.ontimeout = () => {
            deactivate();
            modal.className = "";
        }
        req.open('GET', '/activate?' + ts, true);
        req.send(null);
    }
};

function repeat_sound() {
    sound.currentTime = 0;
    sound.play();
    return setInterval(
        () => {
            sound.currentTime = 0;
            sound.play();
        },
        2000
    );
}

function deactivate() {
    button.className = "activate";
    if (sound_interval_id) {
        clearInterval(sound_interval_id);
        sound.pause();
        sound_interval_id = undefined;
    }
    calling = false;
}

function repeat_status() {
    return setInterval(
        () => {
            let ts = new Date().getTime();
            let req = new XMLHttpRequest();
            req.onreadystatechange = () => {
                if (req.readyState == 4) {
                    if (req.status == 200) {
                        modal.className = "hidden";
                        if (req.responseText == "confirmed") {
                            confirmed = true;
                            if (calling) deactivate();
                        } else {
                            confirmed = false;
                        }
                    } else {
                        deactivate();
                        modal.className = "";
                    }
                }
            }
            req.ontimeout = () => {
                deactivate();
                modal.className = "";
            }
            req.open('GET', '/status?' + ts, true);
            req.send(null);
        },
        500
    );
}


window.onload = () => {
    status_interval_id = repeat_status();
};
</script>
</body>
</html>
```
</div></details>

# さいごに
明日は @okmt765 さんの『M5Stack UnitV2のマイク音声をリアルタイム再生するWebアプリ』です。
どうぞお楽しみに！