Bluetooth Classicの大まかな概念と処理をまとめてみる

# はじめに
お疲れ様です。ぷらなりあです。
今回はコンテスト開始時に "Bluetooth Classic" のドキュメントが散乱していて発狂してしまったのでまとめていきます。
コードもまるごと載せている(クソコードですみません...)のでご参考までに。
この記事は #力強くアウトプットする日 の取り組みで書いた記事となっています。
皆さんも年末に108本のblogを一緒に書いて煩悩を消していきましょう。

# Bluetoothとは
前提として、Bluetoothとは短距離でデバイス同士が通信できる無線通信の規格のことです。Wi-fiと被ることのある2.4GHzの周波数帯を使用しています。なので、環境によって接続のパフォーマンスに差が出ます。コンテストでは無事(大惨事) 動かなくなり、デスマーチが起こることもあります。

# Bluetooth Classicとは
Bluetooth Classicは、**2.4GHz**の79チャネルでデータをストリーミングする低消費電力の無線通信形式です。**ポイント・ツー・ポイント** のデバイス通信をサポートし、音声やプリントデータといった大きいデータを送信することができます。(公式の文言を一部省略、改変しています)。
イメージ的には以下の画像を参考にしてください。
![スクリーンショット 2024-12-29 233939.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3594961/bb295d44-e8e7-7524-300a-91a28abb39a5.png)


# 何ができるの？
ポイントツーポイント通信、つまり機器と機器の一対一通信をすることができます。

# 何ができないの？
ポイントツーマルチ通信、つまり1つの機器に対して複数端末がつながる通信ができません。

# 使うメリットは？
容量の大きいデータを送信できます。
また、作られてから時間が経っているためドキュメントの蓄積がされています。

# 使うデメリットは？
電力消費量が多いです。
また、データの送信速度が遅いです。
ポイントツーポイント通信以外の通信ができません。

# 基礎知識
## UUID
Bluetoothの処理を書くうえで、UUIDが出てきます。
UUIDとは、重複する可能性はあるが、無視できるくらい小さい可能性だから重複しないことにして使っているIDのことです。128 ビットの値で、標準的にはハイフンで5つに区切られた16進数の文字列で表現される形式の36文字の文字列として表されます。
(e. g. 123e4567-e89b-12d3-a456-426614174000 といったように表現)

基本的にデバイスを判別するために使用されています。デバイス接続時にAdapterから値を確認する際に使用することが多いです。

UUIDは自分で考えて作ってよいですが面倒なので、uuid generatorを使うことがおすすめです。
以下のリンクからアクセスできます。
https://www.uuidgenerator.net/

# 実装方法
今回はESP32Devkit-Cを送信側、スマホを受信側として実装します。
ESPではArduinoライブラリ、スマホではkotlinで実装を行います。
継続接続の処理を実装します。

:::note warn
注意
今回の実装はシリアル通信で実装しています。
抽象化などは何もしていないです。ご配慮ください。
:::

## 送信側
送信側の処理順序は以下のようになっています。
![スクリーンショット 2024-12-30 183312.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3594961/d8a6af3c-48e4-9355-89eb-b6ed581e824d.png)

具体的に書くと以下のコードとなります。
```periferal.c
#include <Arduino.h>
#include "BluetoothSerial.h"

const int PIN_1 = 27; //センサを繋いでいるピン番号

BluetoothSerial SerialBT;

void setup() {
  Serial.begin(115200);
  if(!SerialBT.begin("ESP32_21")) {
    Serial.println("Bluetooth初期化が失敗\n");
    while(1); //初期化が成功まで待機し続ける
  }
  Serial.println("BluetoothがESP32_11と初期化されました");
}

void loop() {
  int data1 = analogRead(PIN_1);
  Serial.println(data1);

  SerialBT.println(data1); // セントラルにデータを送信する
  Serial.println("データを送信しました");
  delay(100);
}

```
簡単ですね。また、BluetoothSerial.hではSPPのUUIDがBluetoothSIGによって決められている(正確性は不確かです。すみません...)数値である、"00001101-0000-1000-8000-00805F9B34FB"をセントラル側で使用します。

## 受信側
受信側の処理順序は以下のようになっています。
![スクリーンショット 2024-12-30 183326.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3594961/522f874a-9891-6bf0-faae-b188f94cc4e3.png)

具体的に処理を書くと以下のようになっています。
少し長めなので、コードを全部貼った後に説明をしていきます。

```bluetoothTest.kt
package com.example.myapplicationui1

import android.Manifest
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.view.WindowManager
import androidx.constraintlayout.widget.ConstraintLayout
import android.view.WindowManager.LayoutParams.SCREEN_ORIENTATION_CHANGED
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.myapplicationui1.ui.theme.ActivityEnd
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.IOException
import java.io.InputStream
import java.util.UUID
import java.util.concurrent.CountDownLatch

class BluetoothTestActivity: AppCompatActivity() {

    // TAGs
    private val TAG1 = "BluetoothAdapter"
    private val TAG2 = "BluetoothConnectDevice"
    private val TAG3 = "ReadDatafromBlutooth"
    private val TAG4 = "FaildReadData"
    private val TAG5 = "CloseConnection"

    // About device information
    private val DEVICE_NAME = "ESP32_11"
    private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")

    // BluetoothSettingsValue
    private var inputStream: InputStream? = null

    // BluetoothAdapter接続
    private var bluetoothAdapter: BluetoothAdapter? = null
    private var bluetoothSocket: BluetoothSocket? = null

    // BluetoothValue
    private val REQUEST_ENABLE_BT = 1
    private val REQUEST_PERMISSIONS = 2

    // readDataを管理する変数
    private var isConnected: Boolean = false

    private var stateSendValue: String = "0"

    //
    private var isfinishGame: Boolean = false

    // CountDownLatch for synchronization
    private val latch = CountDownLatch(1)

    private var toastCall: Int = 0

    private val REQUIRED_PERMISSIONS = arrayOf(
        Manifest.permission.BLUETOOTH_CONNECT,
        Manifest.permission.BLUETOOTH_SCAN,
        Manifest.permission.BLUETOOTH
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_gameplay11)
        try {
            // Broadcastを定義
            val filter = IntentFilter().apply {
                addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
                addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
            }
            registerReceiver(bluetoothReceiver, filter)

            initializedBluetooth()

        } catch (e: Exception) {
            Log.d("Error Try method", "${e}")
        }
    }

    private fun checkBluetoothPermissions(): Boolean {
        return REQUIRED_PERMISSIONS.all { permission ->
            ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
        }
    }

    private fun initializedBluetooth() {
        try {
            bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
            when {
                bluetoothAdapter == null -> {
                    expressionToast("Bluetoothをサポートしていません")
                    return
                }
                !bluetoothAdapter!!.isEnabled -> {
                    try {
                        val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
                        startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
                    } catch (e: SecurityException) {
                        Log.e(TAG1, "Failed to Bluetooth ${e.message}")
                        handlePermissionDenied()
                    }
                }
                else -> {
                    checkAndRequestPermissions()
                }
            }
        } catch (e: SecurityException) {
            Log.e(TAG1, "Security error in initializeed Bluetooth: ${e.message}")
        } catch (e: Exception) {
            Log.e(TAG1, "Error in initializeBluetooth: ${e.message}")
            handleError(e)
        }
    }

    private fun handleError(e: Exception) {
        Log.e(TAG1, "Error occurred: ${e.message}")
        runOnUiThread {
            expressionToast("エラーが発生しました")
        }
    }

    private fun checkAndRequestPermissions() {
        val missingPermissions = REQUIRED_PERMISSIONS.filter {
            ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
        }

        when {
            missingPermissions.isEmpty() -> {
                // 全ての権限が許可されている
                connectToDevice()
            }
            missingPermissions.any { permission ->
                ActivityCompat.shouldShowRequestPermissionRationale(this, permission)
            } -> {
                // 権限が必要な理由を説明
                showPermissionRationaleDialog(missingPermissions.toTypedArray())
            }
            else -> {
                // 権限をリクエスト
                ActivityCompat.requestPermissions(
                    this,
                    missingPermissions.toTypedArray(),
                    REQUEST_PERMISSIONS
                )
            }
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when (requestCode) {
            REQUEST_ENABLE_BT -> {
                if (resultCode == Activity.RESULT_OK) {
                    checkAndRequestPermissions()
                } else {
                    expressionToast("Bluetoothを有効にしてください")
                    reconnectToDevice()
                }
            }
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            REQUEST_PERMISSIONS -> {
                if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
                    // 全ての権限が許可された
                    connectToDevice()
                } else {
                    // 一部またはすべての権限が拒否された
                    handlePermissionDenied()
                }
            }
        }
    }

    private fun showPermissionRationaleDialog(permissions: Array<String>) {
        androidx.appcompat.app.AlertDialog.Builder(this)
            .setTitle("権限が必要です")
            .setMessage("Bluetoothデバイスに接続するために権限が必要です。")
            .setPositiveButton("許可する") { _, _ ->
                ActivityCompat.requestPermissions(
                    this,
                    permissions,
                    REQUEST_PERMISSIONS
                )
            }
            .setNegativeButton("キャンセル") { _, _ ->
                expressionToast("Bluetooth機能を使用するには権限が必要です")
                handlePermissionDenied()
            }
            .show()
    }

    private fun handlePermissionDenied() {
        // 永続的に権限が拒否されたかチェック
        val permanentlyDenied = REQUIRED_PERMISSIONS.any { permission ->
            !ActivityCompat.shouldShowRequestPermissionRationale(this, permission)
        }
        if (permanentlyDenied) {
            showSettingsDialog()
        } else {
            expressionToast("Bluetooth機能を使用するには権限が必要です")
            reconnectToDevice()
        }
    }

    private fun showSettingsDialog() {
        androidx.appcompat.app.AlertDialog.Builder(this)
            .setTitle("権限が必要です")
            .setMessage("設定画面から権限を許可してください")
            .setPositiveButton("設定を開く") { _, _ ->
                val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                    data = android.net.Uri.fromParts("package", packageName, null)
                }
                startActivity(intent)
            }
            .setNegativeButton("キャンセル") { _, _ ->
                expressionToast("Bluetooth機能を使用するには権限が必要です")
                reconnectToDevice()
            }
            .show()
    }

    private fun reconnectToDevice() {
        Log.d(TAG1, "reconnect to Device")
        if (!isfinishGame) {
            CoroutineScope(Dispatchers.IO).launch {
                expressionToast("Bluetoothに接続しています...")
                delay(2000)
                Log.d(TAG1, "Now Connecting...")
                initializedBluetooth()
            }
        }
    }

    private fun connectToDevice() {
        if (ContextCompat.checkSelfPermission(
                this, Manifest.permission.BLUETOOTH_CONNECT
            ) == PackageManager.PERMISSION_GRANTED) {
            try {
                Log.d(TAG1, "Permission Available 04")
                val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter?.bondedDevices
                // ペアリングしているデバイスと接続
                val device = pairedDevices?.find { it.name == DEVICE_NAME }
                Log.d(TAG1, "Device is ${device}")

                if(device != null) {
                    Log.d(TAG1, "Permission Available 05")
                    CoroutineScope(Dispatchers.IO).launch {
                        var retryCount = 0
                        val maxRetries = 3
                        val duration = 5000L

                        while (retryCount < maxRetries) {
                            try {
                                Log.d(TAG1, "Attempting to connect, try #$retryCount")
                                withTimeout(duration) {
                                    bluetoothSocket = device.createRfcommSocketToServiceRecord(SPP_UUID)
                                    Log.d(TAG1, "Permission Available 06")
                                    Log.i(TAG2, "BluetoothSocket is:$bluetoothSocket")
                                    bluetoothSocket?.connect()
                                    Log.d(TAG1, "Permission Available 07")
                                }
                                inputStream = bluetoothSocket?.inputStream
                                Log.d(TAG1, "Permission Available 08")
                                outputStream = bluetoothSocket?.outputStream
                                Log.d(TAG1, "Permission Available 09")
                                Log.d(TAG2, "Connected to $DEVICE_NAME")
                                isConnected = true
                                readData()
                                break // 接続が成功した場合、ループを終了
                            } catch (e: IOException) {
                                Log.e(TAG5, "Connection failed due to IOException: ${e.message}")
                                retryCount++
                                if (retryCount >= maxRetries) {
                                    Log.e(TAG5, "Max retries reached. Could not connect.")
                                    reconnectToDevice()
                                }
                            } catch (e: Exception) {
                                Log.e(TAG5, "Connection failed due to Exception: ${e.message}")
                                reconnectToDevice()
                                break // 非IO例外の場合、ループを終了
                            }
                        }
                    }
                }
            } catch (e: Exception) {
                isConnected = false
                reconnectToDevice()
            }
        }
    }

    private suspend fun readData() {
        Log.d(TAG1, "Permission Available 10")
        try {
            val buffer = ByteArray(1024)
            while (isConnected) {
                try {
                    val bytes = inputStream?.read(buffer) ?: 0
                    if (bytes > 0) {
                        var incomingData = String(buffer, 0, bytes)
                        Log.d(TAG3, "Rechieved: $incomingData")
                        if (bytes != null) {
                            stateSendValue = incomingData
                            Log.d(TAG1, "incomingData is not Null")
                        } else {
                            incomingData = stateSendValue
                        }
                        delay(300)
                        Log.e(TAG1, "First riteral is ${incomingData.first()}")
                    }
                } catch (e: Exception) {
                    Log.e(TAG4, "値読み取りエラー: ${e.message}")
                    isConnected = false
                    reconnectToDevice()
                }
            }
        } catch (e: SecurityException) {
            Log.e("SecutiryExcception", "SercurityExceotion is ${e.message}")
        }
    }

    private val bluetoothReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            val action = intent?.action
            when(action) {
                BluetoothDevice.ACTION_ACL_CONNECTED -> {
                    Log.d(TAG1, "Bluetoothに再接続しました")
                }
                BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
                    Log.d(TAG1, "Bluetoothが切断されました")
                    reconnectToDevice()
                }
            }
        }
    }

    private fun expressionToast(text: String) {
        if(toastCall <= 3) {
            runOnUiThread {
                Toast.makeText(this@GamePlay11Activity, "${text}", Toast.LENGTH_SHORT).show()
                Log.d("TOAST", "ToastText is $text")
            }
            toastCall = toastCall + 1
        } else {
            Log.e("TOAST_ERROR", "Toastの呼び出し数が基準を超えました。Toastを表示できません。")
        }
    }

    private fun closeConnection() {
        try {
            inputStream?.close()
            outputStream?.close()
            bluetoothSocket?.close()
            Log.d(TAG5, "Connection closed")
        } catch (e: Exception) {
            Log.e(TAG5, "Error string connection: ${e.message}")
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        unregisterReceiver(bluetoothReceiver)
        closeConnection()
    }
}
```
少しづつ説明していきます。
まずは定数から。
### 定数
`private val DEVICE_NAME = "ESP32_11"` はデバイス名を指しています。ペリフェラルで指定している`SerialBT.begin("ESP32_21")`の`"ESP32_11"`としている部分と同じ名前にしましょう。
- - -
`private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")` はシリアル通信のUUIDを指定しています。
- - -
`private var inputStream: InputStream? = null` ではシリアル通信で受信した値を受け取るためのクラスを定義しています。`var bytes = inputStream?.read(bufffer) // bufferは事前に定義した、val buffer = ByteArray(1024)`で受信した値を受取ることができます。
- - - 
`private var bluetoothAdapter: BluetoothAdapter? = null` でデバイスの受信を準備するアダプタを定義しています。デバイスとスマホを接続する際に `val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter?.bondedDevices` とすることでペアリングしているデバイスを取得します。
- - - 
`private var bluetoothSocket: BluetoothSocket? = null`  でデバイス接続を行うSocketを定義しています。`bluetoothSocket = device.createRfcommSocketToServiceRecord(SPP_UUID)` でSPP_UUIDを持っているデバイスとのSocketを作成し、`bluetoothSocket?.connect()` でSocket経由でデバイスと接続します。

## 各関数について
initializedBluetooth関数では、Bluetooth接続前の初期化を行っています。
以下に処理順序をまとめていきます。

1. bluetoothAdapterでアダプタを取得する(このとき、bluetoothAdapterがnullだった場合はBluetoothにスマホ自体が対応していないのでreturnを返す)
2. bluetoothAdapter!!.isEnabledで、bluetoothAdaperが有効化されているかを確認する。有効化されていない場合は
```
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
```
によってBluetoothを有効化させる要求を表示する。要求が上手く通らなければ永続的にBluetooth権限が拒否されたかを確認する、handlePermissionDenied関数を叩く。
  3. Bluetoothが有効化されている場合はcheckAndRequestPermissionsというパーミッションを確認する関数を叩く。
- - -
checkAndRequestPermissions関数では、Bluetoothに関する権限の有効化が行われているかを確認します。
以下に処理順序をまとめます。
1. REQUIRED_PERMISSIONS.filterで、事前にリストにしていた権限にフィルターをかける。フィルターは、有効化されている権限をリストから取り除いてmissingPermissions変数に保存している。よって、missingPermissions変数の中には有効化されていない権限のリストが入っている。
```
val missingPermissions = REQUIRED_PERMISSIONS.filter {
    ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
```
2. missingPermissions内がemptyか、anyかを判定する。emptyであればconnectToDevice関数を叩いて接続処理へ移る。anyであればforEachの処理をするように、それぞれの、有効化されていない権限の権限付与を要求する。権限が有効化されていなかった場合、showPermissionRationalDialogで権限が必要な理由を説明する。

- - -
connectToDevice関数では、ペアリングしているデバイスとBluetooth接続をします。
以下に処理順序をまとめます。
1. `val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter?.bondedDevices`で接続しているデバイスを取得する
2. `val device = pairedDevices?.find { it.name == DEVICE_NAME }` でDEVICE_NAMEと一致する名前のデバイスを取得する
3. 非同期処理をするためにCoroutineScopeを回します。入力を有効化させるので、スレッドはI/Oスレッドにする
4. `var retryCount = 0`と`val maxRetries = 3`でリクエスト送信の繰り返しを制御し、 `val duration = 5000L`は値取得の待機時間の変数となる
5. retryCount が maxRetriesより少ない場合は繰り返し処理で `bluetoothSocket = device.createRfcommSocketToServiceRecord(SPP_UUID)` によってソケット接続をする
5. `bluetoothSocket?.connect()`でBluetoothデバイスと接続する
6. `inputStream = bluetoothSocket?.inputStream`によってデバイスのinputStreamとのパスを作る
7. readDevice関数を叩いてデバイスから値を読み取る処理に移る。

例外処理として、IOException(入出力に関するエラー)が発生した場合は retrycountをインクリメントしデバイス接続を再び行います。
IOException以外のエラーの場合はbreakでループから出て処理を一時停止しています。
- - -
readDataでは、接続したbluetoothDeviceより値を読み取って`Log.d(TAG3, "Rechieved: $incomingData")`によって出力しています。
以下に処理順序を示します。
1. `val buffer = ByteArray(1024)` でBluetoothデバイスからバイト文字列を受け取る変数を定義する
2. `val bytes = inputStream?.read(buffer) ?: 0` でbytesにBluetoothデバイスから送信された値を読み取る
3. bytesが0より大きいことを確認し、`var incomingData = String(buffer, 0, bytes)` でStringにキャストする。
4. `Log.d(TAG3, "Rechieved: $incomingData")` でBluetoothデバイスから送信した値を表示する
5. 送信されたデータ値がおかしいときに備えて、事前保存をする変数に読み取り後のデータを代入しておく

例外処理として、基本的に接続を管理する変数をfalseにし、再接続処理であるreconnectToDevice関数をたたきます。

- - -
reconnectToDeviceでは、デバイス再接続をしています。
以下に処理順序を示します。
1. 再帰呼び出しを遅くするためにCoroutineScopeでI/Oスレッドで処理をする
2. delayで3000ms処理を遅れさせる
3. initializeBluetooth関数を叩いて再接続処理を行う

# 注意点
以下に処理を書くうえでの注意点をまとめます。
## ヘルパーメソッドについて
関数の名称の先頭に「on」がついている関数は**ヘルパーメソッド**となっています。例を挙げると、onRequestPermissionResultではintentでパーミッション要求をする関数を叩いたときに呼び出されるコールバックとなっていて、仮引数のintentの値を条件分岐させてリクエストが成功した場合、失敗した場合の処理をまとめることができます。

## 例外処理をするときの注意
例外処理をする場合は、Bluetooth接続や出入力処理を書く際はIOExceptionのワーニングを利用しましょう。また、BroadCastで例外を管理するほうが広いスコープに値を送信できます。try文内で例外処理関数を叩くのか、BroadCastで例外処理をするのかは場合とプロジェクトで異なるかと思います。
個人的には長めの処理になることを防ぐためにBroadCastに処理を集約することが良いかと考えています。

## 非同期処理をするときの注意
Bleutooth Classicは処理速度が遅いです。1秒も受信に時間がかかる場合があるので、タイムアウトの処理をしっかり書いてください。書いていない場合は処理が中断され、接続処理を再度行う必要があります。

# まとめ
BluetoothClassicを使った実装の説明はいかがだったでしょうか？
大前提として、大きめのデータを1回のみ送る、ということをしない場合は完全にBluetoothLowEnergy(BLE)が上位互換となります。実装は難しいですがBLEで基本的に接続をしたほうが消費電力が少なく、ポイントツーポイント通信以外の処理も可能なのでBLEで実装することをおすすめしたいです。
皆さん、気になったら実装してみてください。
# 参考文献
↓BluetoothSPP

https://qiita.com/overheattrigger/items/27b5df1a15d982e2eaa4

https://source.android.com/docs/core/connect/bluetooth?hl=ja

↓BroadCast

https://qiita.com/kewpie134134/items/ca6fac2834d5a3052237