2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android11のスマホに電話の着信があった時にESP32でLEDを光らせ知らせるようにしてみた

Posted at

Android11のスマホに電話の着信があった時にESP32でLEDを光らせて知らせる

一連のアプリや電子工作をしてみたので、自分用の備忘録として記事にします。


作るきっかけ

私は排水処理場で働いており、恥ずかしながら私用の携帯を 1年で2回も処理槽に落とす。という失態をしてしまいました。

社用の携帯をもし落とすと始末書を書くことになりますので、社用の携帯は事務室に放置し、私用の携帯に転送して対応していたのですが、慌てていたりすると、長めの紐をつけていたりしてもポケットから出す際に手が滑って槽に没してしまいます。また回転機器もあるため、首からぶら下げるのも危険です。

しかし私用の携帯を2度も続けて落としたことで、私用ですら持ち歩くのが怖くなってしまいました。

そこで
社用の携帯に着信があった場合に知らせてくれる仕組み
を作ろうと思い立ち、今回の一連のアプリや電子工作で対応することにしました。
実際にはLEDとブザーで知らせてくれるようにしています。

また、今回は作成に当たり文明の利器であるAIを多分に活用させてもらっています。
AIによって書き方に癖があったりするので
色々なAIに尋ねて、最善のものを選ぶ形で組んでいます。


システムの概要

今回構成したのは以下の4ステップです。

  1. 社用の携帯にインストールするアプリ

    • 着信を検知して PHP に情報を送信
    • Visual Studio 2022 の C# で作成
    • 社用の携帯のAndroid 11 で動作確認済み
  2. アプリから送信された情報を受け取る PHPで HTML 更新

    • 受信したデータ内容をHTMLにそのまま追記
  3. 更新された HTML を検知して LED を点灯させる ESP32

    • Arduino IDE でコード作成
    • ESP32 マイコンを使用
    • Wi-Fi のあるネット環境が必須
  4. 私用の携帯でも着信を確認するアプリ

    • 私用の携帯は今のは電話は出来ずデータ通信機能のみです。
    • 更新された HTML を検知して通知表示
    • 私用の携帯の Android 11 で動作確認済み
    • (社用携帯はたまに事務室に置きっぱなしで忘れて帰ることもあるため)

以上のステップを図示すると以下のようになります
image.png


各コードの内容

1. 社用の携帯にインストールするアプリのコード

Visual Studio 2022の新しいプロジェクトの作成から
C#のAndroidアプリケーションを選択し以下のように記述しました。
なお、任意で変更可能なアプリのアイコン設定などは省略しています。
また、PHPは、シンフリーサーバーにあるものを使用しています。

MainActivity.csに面倒なので行儀悪いですがすべて一括で以下のように記述しています。

MainActivity.cs
using Android.App;
using Android.Content;
using Android.OS;
using Android.Widget;
using Android.Content.PM;
using Android.Telephony;

namespace MyTestApp;

[Activity(Label = "@string/app_name", MainLauncher = true)]
public class MainActivity : Activity
{
    protected override void OnCreate(Bundle? savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // UI設定
        SetContentView(MyTestApp.Resource.Layout.Layout1);
        var button = FindViewById<Button>(MyTestApp.Resource.Id.startButton);
        button.Click += (sender, e) =>
        {
            var intent = new Intent(this, typeof(CallMonitorService));
            if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
            {
                StartForegroundService(intent);
            }
            else
            {
                StartService(intent);
            }
        };

        // Android 13 (API 33) 以降の通知権限をリクエスト
        if (Build.VERSION.SdkInt >= BuildVersionCodes.Tiramisu)
        {
            if (CheckSelfPermission(Android.Manifest.Permission.PostNotifications) != Permission.Granted)
            {
                RequestPermissions(new[] { Android.Manifest.Permission.PostNotifications }, 1001);
            }
        }
        // 実行時にパーミッションをリクエスト
        if (CheckSelfPermission(Android.Manifest.Permission.ReadPhoneState) != Permission.Granted)
        {
            RequestPermissions(new[] { Android.Manifest.Permission.ReadPhoneState }, 2001);
        }
    }
    public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
    {
        if (requestCode == 2001)
        {
            if (grantResults.Length > 0 && grantResults[0] == Permission.Granted)
            {
                Toast.MakeText(Application.Context, "READ_PHONE_STATE 権限が許可されました。", ToastLength.Short).Show();
            }
            else
            {

            }
        }
        base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}

[Service(Exported = false, ForegroundServiceType = Android.Content.PM.ForegroundService.TypePhoneCall)]
public class CallMonitorService : Service
{
    TelephonyManager telephonyManager;
    MyPhoneStateListener phoneStateListener;

    public override void OnCreate()
    {
        base.OnCreate();
        CreateNotificationChannel();
        telephonyManager = (TelephonyManager)GetSystemService(TelephonyService);
        phoneStateListener = new MyPhoneStateListener(this);
#pragma warning disable CS0618
        // Android S (API 31) 未満ではこの方法を使用
        telephonyManager.Listen(phoneStateListener, PhoneStateListenerFlags.CallState);
#pragma warning restore CS0618
    }

    public override StartCommandResult OnStartCommand(Intent intent, StartCommandFlags flags, int startId)
    {
        var notification = new Notification.Builder(this, "call_monitor_channel")
            .SetContentTitle("着信監視サービス稼働中")
            .SetContentText("キャリア電話の着信を監視しています")
            .SetSmallIcon(Android.Resource.Drawable.IcDialogInfo)
            .SetOngoing(true)
            .Build();

        StartForeground(1, notification);
        return StartCommandResult.Sticky;
    }

    public override IBinder OnBind(Intent intent) => null;

    public override void OnDestroy()
    {
        base.OnDestroy();
        // サービス停止時にリスナーを解除
        telephonyManager.Listen(phoneStateListener, PhoneStateListenerFlags.None);
    }

    private void CreateNotificationChannel()
    {
        if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
        {
            var channel = new NotificationChannel("call_monitor_channel",
                "着信監視サービス",
                NotificationImportance.Low)
            {
                Description = "キャリア電話の着信を監視します"
            };

            var manager = (NotificationManager)GetSystemService(NotificationService);
            manager.CreateNotificationChannel(channel);
        }
    }
}

public class MyPhoneStateListener : PhoneStateListener
{
    private readonly Context _context;

    // コンストラクタを追加し、Contextを受け取る
    public MyPhoneStateListener(Context context)
    {
        _context = context;
    }

    public override async void OnCallStateChanged(CallState state, string incomingNumber)
    {
        base.OnCallStateChanged(state, incomingNumber);

        if (state == CallState.Ringing)
        {
            Toast.MakeText(Application.Context, "キャリア電話の着信を検知しました", ToastLength.Short).Show();
            try
            {
                using var client = new HttpClient();

                string currentTime = DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss");
                string url = "http://[※シンフリーサーバーのID].cloudfree.jp/your_script.php?sendtxt=called" + currentTime;
                HttpResponseMessage response = await client.GetAsync(url);
                response.EnsureSuccessStatusCode(); // HTTP 4xx/5xx エラーの場合は例外をスロー

            }
            catch (System.Exception ex)
            {
                Toast.MakeText(Application.Context, "送信エラー", ToastLength.Short).Show();
            }
        }
    }

}
Resources\layout\Layout1.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
	<Button
		android:text="監視開始"
		android:textSize="10.0dp"
		android:layout_width="match_parent"
		android:layout_height="45.0dp"
		android:id="@+id/startButton"
		android:gravity="center"
					/>
</LinearLayout>
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
	<application android:allowBackup="true" android:icon="@drawable/pp1756209753674" android:label="@string/app_name" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config">
		<service android:name=".CallMonitorService" android:exported="false" android:foregroundServiceType="phoneCall" />
	</application>
	<uses-permission android:name="android.permission.INTERNET" />
	<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
	<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
	<uses-permission android:name="android.permission.READ_PHONE_STATE" />
</manifest>

Resourcesにxmlというフォルダを新規作成し
同フォルダ内にnetwork_security_config.xmlを新規作成して
以下のように記述しています。

Resources\xml\network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
	<domain-config cleartextTrafficPermitted="true">
		<domain includeSubdomains="true">[※シンフリーサーバーのID].cloudfree.jp</domain>
	</domain-config>
</network-security-config>

2. アプリから送信された情報を受け取る PHPで HTML 更新のコード
上記の通りPHP本体は、シンフリーサーバー上にあります。

your_script.php
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width">
    <title>マイコン動作用PHP</title>
</head>
<body>

<?php

if (!isset($_GET['sendtxt'])) {
  $sendtxt ='';
} else {
  $sendtxt = $_GET['sendtxt'];  /* sendtxtデータ */
}


$file = 'sendtxt.html';
//テキストファイルに上書きで、書き出す。

if ($sendtxt != '' ){
    //前回のpingの内容を読み込む
    $sendtxt_data = file_get_contents($file);

    //改行以外のタグを抜く
    //https://www.php.net/manual/ja/function.strip-tags.php
    //http://phpspot.net/php/man/php/function.strip-tags.html
    $sendtxt_data = strip_tags($sendtxt_data, '<br>');

    //タイトルを抜く
    $sendtxt_data  = str_replace('called_log', '', $sendtxt_data);

    //50000文字に制限する
    //https://blog.codecamp.jp/php-substr
    $str_len2 = strlen($sendtxt_data);
    if ($str_len2 > 50000){
        $sendtxt_data = substr($sendtxt_data, 0, 50000);
    }

    //今回の情報を書き込加えておく
    $sendtxt = $sendtxt . '<br />' . "\n" . $sendtxt_data;

    //HTMLを完結させる
    $sendtxt = '<!doctype html><html><head><meta charset="utf-8"><title>called_log</title></head><body>' . $sendtxt;
    $sendtxt = $sendtxt . '<br /><br /></body></html>';

    //ファイルに出力する
    file_put_contents($file, $sendtxt);
}


?>
</body>
</html>

3. 更新された HTML を検知して LED を点灯させる ESP32のコード
ESP32マイコンボードESP32 DevKitの
23ピンにはLEDとブザーが接続されていて
22ピンにはブザー解除用のボタンが接続されています。

sketch_sep6a.ino
#include <WiFi.h>
#include <HTTPClient.h>

// Wi-Fi設定
const char* ssid = "WiFiのSSID";
const char* password = "WiFiのパスワード";

// HTTP URL
const char* host = "[※シンフリーサーバーのID].cloudfree.jp";
const int port = 80;
const char* path = "/sendtxt.html";

// GPIO設定(ESP32 DevKitなら2番ピンにLEDがある)
const int LED_PIN = 23; //電話着信があった時に光るLED
const int INPUT_PIN = 22; //電話着信確認をしてLEDを消すためのボタン
const int RUN_PIN = 2; //Webページ読み込み状態確認用LED

String lastSeenFirstLine = ""; // 前回取得したボディ内の最初の行を保存

void setup() {
  Serial.begin(115200);
  delay(1000);

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(RUN_PIN, OUTPUT);
  digitalWrite(RUN_PIN, LOW);

  pinMode(INPUT_PIN, INPUT_PULLUP);

  // WiFi接続
  WiFi.begin(ssid, password);
  Serial.print("WiFiに接続中");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi接続完了");
}

void loop() {
  if (WiFi.status() == WL_CONNECTED) {
    HTTPClient http;
    http.setTimeout(40000);
    http.begin(host, port, path);

    int httpCode = http.GET();
    if (httpCode == 200) {
      digitalWrite(RUN_PIN, HIGH);

      String payload = http.getString(); // ページ本文全体を取得

      // <body>タグの位置を見つける
      int bodyStart = payload.indexOf("<body>");
      if (bodyStart >= 0) {
        // <body>タグ以降の文字列を抽出
        String bodyContent = payload.substring(bodyStart + 6); // "<body>"の6文字をスキップ

        // 最初の<br />または改行コードの位置を見つける
        int firstBreak = bodyContent.indexOf("<br />");
        int firstNewLine = bodyContent.indexOf('\n');
        int firstLineEnd = -1;

        if (firstBreak >= 0 && firstNewLine >= 0) {
            firstLineEnd = min(firstBreak, firstNewLine);
        } else if (firstBreak >= 0) {
            firstLineEnd = firstBreak;
        } else if (firstNewLine >= 0) {
            firstLineEnd = firstNewLine;
        }

        String firstLine;
        if (firstLineEnd > 0) {
          firstLine = bodyContent.substring(0, firstLineEnd);
        } else {
          firstLine = bodyContent; // 改行や<br />がない場合は全文
        }
        
        firstLine.trim(); // 前後の空白を削除

        if (firstLine.length() > 0) {
          Serial.println("サーバの最初の行: " + firstLine);

          // 比較して更新があればLED点灯
          if (lastSeenFirstLine.length() > 0 && firstLine != lastSeenFirstLine) {
            Serial.println("更新検知! → LED点灯");
            digitalWrite(LED_PIN, HIGH);
            delay(5000); // LED点灯
            // digitalWrite(LED_PIN, LOW);
          }
          
          lastSeenFirstLine = firstLine; // 状態を更新
        }
      }
    } else {
      Serial.printf("HTTPリクエスト失敗: %s\n", http.errorToString(httpCode).c_str());
    }
    http.end();
  } else {
    Serial.println("WiFi切断中 → 再接続");
    WiFi.reconnect();
  }

  int var = 0;
  while(var < 10){ // 1分ごとにチェック
    // この部分が10回繰り返される
    Serial.println("ボタン状態 : " + String(digitalRead(INPUT_PIN)));

    if(digitalRead(INPUT_PIN)==0){  // ブザー解除ボタン(タクトスイッチ)の状態を読み取る
      int tim_cnt = 0;
      int push_cnt = 0;
      while (tim_cnt < 10) {  // 10回サンプリング(約500ms)
        if (digitalRead(INPUT_PIN) == 0) {
          push_cnt++;
        }
        delay(50); // 50msごとにチェック
        tim_cnt++;
      }
      if (push_cnt > 7) {  // 10回中7回以上LOWなら「押された」と判定
        digitalWrite(LED_PIN, LOW);
      }
    }
    delay(6000);
    var++;
  }
  digitalWrite(RUN_PIN, LOW);
}

4.私用の携帯でも着信を確認するアプリのコード
1項目と同様にVisual Studio 2022の新しいプロジェクトの作成から
C#のAndroidアプリケーションを選択し以下のように記述しました。
本当は、1項目のアプリをコピーして書き換えたものですが。
尚、任意で変更できるアプリのアイコンなどの記述はないです。

MainActivity.csに面倒なので行儀悪いですがすべて一括で以下のように記述しています。

MainActivity.cs
using System;
using System.Net.Http;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using Android.App;
using Android.Content;
using Android.OS;
using Android.Widget;
using Android.Content.PM;
using Android.Util;

namespace MyTestApp;

[Activity(Label = "@string/app_name", MainLauncher = true)]
public class MainActivity : Activity
{
    protected override void OnCreate(Bundle? savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // UI設定
        SetContentView(MyTestApp.Resource.Layout.Layout1);
        var button = FindViewById<Button>(MyTestApp.Resource.Id.startButton);
        button.Click += (sender, e) =>
        {
            var intent = new Intent(this, typeof(PageMonitorService));
            if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
            {
                StartForegroundService(intent);
            }
            else
            {
                StartService(intent);
            }
            Toast.MakeText(this, "監視サービスを開始しました", ToastLength.Short).Show();
        };

        // Android 13 (API 33) 以降の通知権限をリクエスト
        if (Build.VERSION.SdkInt >= BuildVersionCodes.Tiramisu)
        {
            if (CheckSelfPermission(Android.Manifest.Permission.PostNotifications) != Permission.Granted)
            {
                RequestPermissions(new[] { Android.Manifest.Permission.PostNotifications }, 1001);
            }
        }
    }

    public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
    {
        if (requestCode == 1001)
        {
            if (grantResults.Length > 0 && grantResults[0] == Permission.Granted)
            {
                Toast.MakeText(this, "通知権限が許可されました。", ToastLength.Short).Show();
            }
            else
            {
                Toast.MakeText(this, "通知権限がないため、通知が表示されない場合があります。", ToastLength.Long).Show();
            }
        }
        base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}

// ------------------------------------------------------------------------------------------------------------------------

[Service(Exported = false, ForegroundServiceType = Android.Content.PM.ForegroundService.TypeDataSync)]
public class PageMonitorService : Service
{
    private const string Url = "http://[※シンフリーサーバーのID].cloudfree.jp/sendtxt.html";
    private const string NOTIFICATION_CHANNEL_ID = "PageMonitorChannel";
    private static string _lastSeenFirstLine = string.Empty;
    private static readonly HttpClient _client = new HttpClient();
    private Task _monitorTask;

    public override void OnCreate()
    {
        base.OnCreate();
        CreateNotificationChannel();
    }

    public override StartCommandResult OnStartCommand(Intent intent, StartCommandFlags flags, int startId)
    {
        var notification = new Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
            .SetContentTitle("電話の着信監視中")
            .SetContentText("電話着信があれば通知します")
            .SetSmallIcon(Android.Resource.Drawable.IcDialogInfo)
            .SetOngoing(true)
            .Build();

        StartForeground(1, notification);

        // 監視タスクを開始
        _monitorTask = StartMonitoringAsync();

        return StartCommandResult.Sticky;
    }

    public override IBinder OnBind(Intent intent) => null;

    public override void OnDestroy()
    {
        base.OnDestroy();
        // サービス停止時にタスクをキャンセル
        _monitorTask?.Dispose();
    }

    private async Task StartMonitoringAsync()
    {
        while (true)
        {
            try
            {
                string htmlContent = await _client.GetStringAsync(Url);
                string firstLine = ExtractFirstLineFromBody(htmlContent);

                if (!string.IsNullOrEmpty(firstLine))
                {
                    Log.Info("PageMonitor", "サーバの最初の行: " + firstLine);

                    if (!string.IsNullOrEmpty(_lastSeenFirstLine) && firstLine != _lastSeenFirstLine)
                    {
                        // 更新を検知した場合
                        ShowNotification("電話着信検知!", "※新しい着信" + firstLine);
                        Log.Info("PageMonitor", "更新検知! → 通知を送信");
                    }

                    _lastSeenFirstLine = firstLine; // 状態を更新
                }
            }
            catch (Exception ex)
            {
                Log.Error("PageMonitor", $"HTTPリクエスト失敗: {ex.Message}");
            }

            // 1分ごとにチェック
            await Task.Delay(60000);
        }
    }

    private string ExtractFirstLineFromBody(string html)
    {
        var bodyMatch = Regex.Match(html, @"<body>(.*?)<\/body>", RegexOptions.Singleline);
        if (bodyMatch.Success)
        {
            string bodyContent = bodyMatch.Groups[1].Value.Trim();
            var lineMatch = Regex.Match(bodyContent, @"^(.*?)(<br \/>|\n)");
            if (lineMatch.Success)
            {
                return lineMatch.Groups[1].Value.Trim();
            }
            return bodyContent; // 改行がない場合
        }
        return string.Empty;
    }

    private void CreateNotificationChannel()
    {
        if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
        {
            var channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, "携帯電話着信の監視通知", NotificationImportance.High);
            var notificationManager = (NotificationManager)GetSystemService(Context.NotificationService);
            notificationManager.CreateNotificationChannel(channel);
        }
    }

    private void ShowNotification(string title, string message)
    {
        var builder = new Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
            .SetContentTitle(title)
            .SetContentText(message)
            .SetSmallIcon(Android.Resource.Drawable.IcDialogInfo)
            .SetAutoCancel(true);

        var notificationManager = (NotificationManager)GetSystemService(Context.NotificationService);
        notificationManager.Notify(0, builder.Build());
    }
}
Resources\layout\Layout1.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
	<Button
		android:text="電話着信の監視の開始"
		android:textSize="10.0dp"
		android:layout_width="match_parent"
		android:layout_height="45.0dp"
		android:id="@+id/startButton"
		android:gravity="center"
					/>
</LinearLayout>
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
	<application android:allowBackup="true" android:icon="@drawable/pp250816094355426" android:label="@string/app_name" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config">
		<service android:name=".CallMonitorService" android:exported="false" android:foregroundServiceType="phoneCall" />
	</application>
	<uses-permission android:name="android.permission.INTERNET" />
	<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</manifest>

Resourcesにxmlというフォルダを新規作成し
同フォルダ内にnetwork_security_config.xmlを新規作成して
以下のように記述しています。

Resources\xml\network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
	<domain-config cleartextTrafficPermitted="true">
		<domain includeSubdomains="true">[※シンフリーサーバーのID].cloudfree.jp</domain>
	</domain-config>
</network-security-config>

今後の予定や改善点

  • アプリのnetwork_security_config.xmlに監視先のサーバーを直接記述していたり、PHPの送信先が固定なので汎用性がなく
    広く公開できない私だけの専用の仕様なので、今後は広く公開できるように改善できればいいなと考えています。
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?