Help us understand the problem. What is going on with this article?

THETAプラグインで複数のSwitchBot温湿度計と連携する

はじめに

リコーの @KA-2 です。

弊社ではRICOH THETAという全周囲360度撮れるカメラを出しています。
RICOH THETA VRICOH THETA Z1は、OSにAndroidを採用しています。Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます(詳細は本記事の末尾を参照)。

2020年6月17日のファームウェアアップデートにてBluetooth Classic機器連携が解禁されまして、「Bluetooth AudioデバイスからTHETAを操る」のような記事もでているのですが。
ひきつづきBLE機器との連携もでき、上記対応によりBLEまわりにちょっと変更があります。

変更された事項を含めBLE周りをひととおり説明したいなーと思い、今回は、「BLE機器であるSwitchBot温湿度計と連携して、冷蔵庫を開くとTHETAで撮影する」というサンプルを作ってみました。

もうちょっと詳しくやっていることを書くと以下のとおり。

  • 複数のSwitchBot温湿度計がアドバタイズしている温度・湿度を取得しWebUIに一覧表示(動的更新)
  • 一覧から選択した1つの温湿度計の湿度履歴をWebUIにグラフ表示(動的更新)
  • 一覧から選択した1つの温湿度計の湿度急上昇をトリガーに撮影する(判定はTHETA内です。WebUIは視覚化するためのもので必須ではありません)

動作させていている動画はこちら。

それでは、詳しく説明していきましょう。

SwitchBot温湿度計とは

Wonderlabs,Inc.が販売している スマートホームを実現するための製品群「SwitchBotシリーズ」の一つです。

01_SwitchBotシリーズ.jpg

SwitchBotシリーズは、単体でもスマートフォンと直接連携して使用できますが、「SwitchBotハブプラス」「SwitchBotハブミニ」と組み合わせると、複数種類の機器連携やクラウド連携が行えるようになります。

SwitchBotシリーズのなかで、BLEで通信を行うものが、「SwitchBot温湿度計」「SwitchBotボット」で、APIの仕様がこちらに公開されています。(一部日本では販売していない製品の仕様も含まれます)

APIの仕様をみるとわかるのですが、サービスデータの前半3バイトはBLE対応機器共通です。
今回の記事、タイトルに「SwitchBot温湿度計」を強調してますが、「SwitchBotボット」のアドバタイズデータも同時に扱っています(機器一覧に出ますが、トリガーには使えないようになってます)。

今回作成したもの

今回作成したプラグインがどんな動作をするのか簡単な図にしてみました。
画像がTHETA Z1ですがTHETA Vでも利用できます。

記事画像作成.jpg

SwitchBot温湿度計、SwitchBotボットがアドバタイズしているデータをTHETAが読み取り、なんらかのアクションを行わせることができます。今回は、エイヤで湿度急上昇トリガーで撮影としていますが、他にも多様なことがかんがえられます(末尾のほうの章にいくらか書いてあります)。

THETAのwifi(APモード/CLモード)もBLEのスキャンと同時に動作できています。
今回はWebUIで利用していますが、クラウド連携もさせた場合、THETAを「SwitchBotハブ」「SwitchBotハブミニ」の代替機のように振舞わせることも可能です(SwitchBot専用のクラウドまわりはAPIなど公開されてないようですので、別のクラウド連携となりますが、なんでも可能なレベルですね)。
今回の事例はその片鱗(大切な要素は凝縮)までを実装しています。

今回、アドバタイズデータを読み取っているだけな点はご容赦を。
接続してさらにread/writeまですると、SwitchBot温湿度計の場合、SwitchBot温湿度計内のログを取得したりイベント通知の設定が行えます。SwitchBotボットの場合アームを動作させられます。このあたりが必要な場合にはご自身で拡張してください。

Bluetooth関連のTHETA固有ノウハウ(要注意)

ソースコードの説明と共に記載すると大切なことが埋もれてしまうので、前半に説明を記載します。
ひとまず読み飛ばして、実際にコードを修正して動作させるときに読めばOKです。

Bluetoothの状態表示(Z1)と内部状態

Bluetooth AudioデバイスからTHETAを操る」の記事にて、Bluetoothリモコン機能Bluetoothモジュールの電源について少し触れていますが、

  • Z1のOLED左上に表示されるBluetoothマーク、リモコンマークと上記の設定値の関係が記されていなかった。
  • BLE用の設定(_bluetoothPowerを「ON」、_bluetoothRoleを「Peripheral」)をした場合、切り替えたあとの振る舞いに癖があった。

ので以下にまとめます。

No Bluetooth
マーク(Z1)
リモコン
マーク(Z1)
_bluetoothPower _bluetoothRole 必要な切り替わり待ち時間
1 あり あり ON Central_Peripheral 約10sec
2 あり なし ON Peripheral 不要
3 なし あり ON Central 約10sec
4 なし なし OFF Peripheral 約500msec

[No1,No3の「約10sec」の待ちについて]
今回のプラグインは「スキャンを連続動作」させているのですが、待ち時間が不十分だと、早々にスキャン動作が止められていました。これはプラグイン起動前のリモコン探索動作が10秒周期で行われていることと関連があると思われます。おそらく、タイマーハンドラなどで動作していて、スキャン動作を止めるタイミングが10秒間隔でやってくるのでしょう。この振る舞いを避けるために10秒待ちをしています。(プラグイン立ち上がりが遅延することはありません。最初のスキャン結果が得られるのが10秒程度遅れるだけです。)

[No4の「約500msec」の待ちについて]
Bluetooth Audioデバイス連携」の時には待ち時間なしでも問題なかったようですが、今回は、待ち時間が100msでNG(プラグインがエラー音と共に強制終了する)、 200msはOK/NGが時々で変わるような振る舞いだったので、多目にマージンを確保して500msとしています。対症療法的な問題回避です。

BLEスキャン動作とwifiの干渉と回避策

BLEとwifiの電波はどちらも2.4GHz帯を利用しています。このため、BLEスキャンを連続して行う本プラグインでは、wifiの利用周波数帯を2.4GHzとしていると、干渉によってWebUIの更新が高頻度に邪魔される状態になります。(BLEは2.4GHz帯を40個のチャンネルに分割し、内3チャンネルをアドバタイズ用(=スキャン対象のチャンネル)に利用しています。このチャンネルは2.4GHz帯の中でもwifiと干渉しやすいです)。

このため、wifiの利用周波数帯を5GHzとすることで、BLEのスキャンとWebUIを両立できます。ただし、日本では屋内でのみ 5GHz帯を使用するようにしてください。
(WebAPIを使いプラグインからでも設定可能ですが、接続中は変更結果が反映されません。このプラグインを動作させる前にスマートフォン基本アプリから設定しておいてください)

補足として、一般的なBLEのアプリケーションでは、BLEスキャン動作を連続して長時間行うことがありませんし(スキャンにより対象機器を見つけたら、コネクションを貼って、別のチャンネルでデータ通信を行います)、BLEの通信頻度は低く、ホッピングの仕組みもあるので、滅多に干渉が問題になることはありません。

プログラムの説明

こちらのプロジェクト一式を参照してください。
詳細な説明を以下に記載します。

ファイル構成

こちらの記事のソースコードをベースに作成しました。
新規作成、または、変更を加えたファイルは以下のとおりです。

theta-plugin-switchbot-link\app\src\main
├assets           // index.htmlに手を加えました。
│ └js             //preview.jsに手を加え、Chart.js(MITライセンスのライブラリ)が新規です。
└java\com\theta360
 ├pluginapplication
 │ └task        // PrepareBluetoothTask.java が新規です。
 └switchbotlink // MainActivity.javaとWebServer.javaに手を加え、SwitchBot.javaが新規です。

Bluetooth関連のパーミッション

BLEの通信を行うため、AndroidManifest.xmlに追加した定義は以下です。

AndroidManifest.xml
    <uses-permission android:name="android.permission.BLUETOOTH"/>
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <uses-feature android:name="android.hardware.bluetooth" />

デバッグ時には、apkをインストールしたあと、Vysorを使い、このプラグインに位置情報のパーミッションを手動で与えてから動作させてください。
ストアからのインストール時には自動でパーミッションが与えられますのでデバッグの時だけの操作となります。

BLEを利用するための設定と復帰

BLEを利用するためには、「Bluetoothの状態表示(Z1)と内部状態」の章で説明したとおり、_bluetoothPowerを「ON」、_bluetoothRoleを「Peripheral」にする必要があります。

そしてプラグイン終了時には、プラグイン起動前の状態に戻すのがスマートです(必須ではないですが、ユーザーに対して優しいですね)。

それらの処理を一括しておこなっているのがPrepareBluetoothTask.javaに記載したタスクです。
コードの掲載は割愛しますが、設定と復帰の両方が行えるよう工夫してあります。

設定を整えるには BLEの初期化を行う前でタスクを呼び出します。
このときの第二引数と第三引数に意味はありません。第四引数をtrueにしておくとコールバック関数が呼ばれ、「変更前の状態を退避」と「BLEの初期化とスキャン開始」を行います。

MainActivity.java
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        省略
        // Bluetooth周りを整え初期化する
        new PrepareBluetoothTask( mPrepareBluetoothTaskCallback, "ON", "Peripheral" , true).execute();
        省略
    }

変更した状態を復帰するには、onPause()でこのタスクを呼び出します。
第二引数と第三引数は、コールバック関数で退避しておいた元の状態を与えます。第四引数はfalseとし、コールバック関数が呼ばれないようにします。

MainActivity.java
    @Override
    protected void onPause() {
        省略
        // Bluetooth周りをプラグイン起動前の状態に戻す(初期化なし→フラグをfalseで呼ぶ)
        new PrepareBluetoothTask( mPrepareBluetoothTaskCallback, restoreBluetoothPoewr, restoreBluetoothRole , false).execute();
        省略
    }

Bluetooth初期化とスキャン開始

前述のコールバック関数の中身となります。
「Bluetoothの状態表示(Z1)と内部状態」の章で説明した「待ち時間の調節」もここで行っています。

スキャンパラメータとして与えているUUIDはSwitchBotのAPIドキュメントのこちらに記されています。
SwitchBot関連製品の中でBLEでアドバタイズしている機器すべてがスキャン対象となります。

スキャン頻度に関しては今回「SCAN_MODE_LOW_LATENCY」を設定し常時スキャンを継続するようにしています。「SCAN_MODE_BALANCED」などにすると間欠動作となり、反応が鈍くなってしまうためです。
目的に応じたスキャン動作となるように設定してください。

MainActivity.java
    private String restoreBluetoothPoewr;
    private String restoreBluetoothRole;
    private PrepareBluetoothTask.Callback mPrepareBluetoothTaskCallback = new PrepareBluetoothTask.Callback() {
        @Override
        public void onPrepareBluetooth( String saveBluetoothPower, String saveBluetoothRole, boolean inInitFlag) {
            restoreBluetoothPoewr = saveBluetoothPower;
            restoreBluetoothRole = saveBluetoothRole;

            if (inInitFlag) {
                int initWaitMs = 0;
                if ( !saveBluetoothRole.equals("Peripheral") ) {
                    //Wait for the remote control scanner to finish
                    initWaitMs = 10000;
                } else {
                    if (saveBluetoothPower.equals("OFF")) {
                        //Wait for the _bluetoothPower turn ON
                        initWaitMs = 500;
                    }
                }
                if (initWaitMs!=0) {
                    Log.d(TAG,"Init wait " + String.valueOf(initWaitMs) + "msec");
                    try {
                        Thread.sleep(initWaitMs);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

                //BLE Init
                Log.d(TAG,"#### BLE Init START ####");
                final BluetoothManager bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
                bluetoothAdapter = bluetoothManager.getAdapter();
                mBluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();


                Log.d(TAG,"#### Set BLE Scan PARAM ####");
                ScanFilter scanFilter = new ScanFilter.Builder()
                        .setServiceUuid( ParcelUuid.fromString("cba20d00-224d-11e6-9fb8-0002a5d5c51b") )
                        .build();
                ArrayList scanFilterList = new ArrayList();
                scanFilterList.add(scanFilter);

                ScanSettings scanSettings = new ScanSettings.Builder()
                        //.setScanMode(ScanSettings.SCAN_MODE_BALANCED).build();  //鈍くなるので監視には不向き
                        .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(); //たくさんとれるが負荷高い。

                Log.d(TAG,"#### BLE Scan START ####");
                mBluetoothLeScanner.startScan(scanFilterList, scanSettings, mScanCallback);
            }
        }
    };

スキャン結果取得と蓄積

スキャン結果は所定のコールバック関数で受け取ります。コードは以下のとおり。

MainActivity.java
    //BLE Resorce
    private BluetoothAdapter bluetoothAdapter = null;
    private BluetoothLeScanner mBluetoothLeScanner = null;

    private ScanCallback mScanCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            Log.d(TAG,"BLE:onScanResult() Callback");
            Log.d(TAG,"BLE:result = " + result.toString());

            byte[] bServiceData = result.getScanRecord().getServiceData( ParcelUuid.fromString("00000d00-0000-1000-8000-00805f9b34fb") ).clone();
            String hexString ="";
            for(int i=0; i< bServiceData.length; i++) {
                hexString += String.format("%02X", bServiceData[i]);
            }
            Log.d(TAG, "MAC:" + result.getDevice().toString() + ", RSSI:" + result.getRssi() + ", TimestampNano:" + result.getTimestampNanos() + ", ServiceData:0x" + hexString );

            //スキャン結果をログに保存 (すでにログがあれば追加、なければ新規)
            SwitchBot tempMember = new SwitchBot(result.getDevice().toString(), result.getRssi(), result.getTimestampNanos(), bServiceData);
            shootingJudgmentOnHumidity(tempMember.macAddress, tempMember.humidity);
            enterScanLog(tempMember);
        }

        @Override
        public void onBatchScanResults(List<ScanResult> results) {
            Log.d(TAG,"BLE:onBatchScanResults() Callback");
            for ( int i=0; i<results.size(); i++ ){
                Log.d(TAG,"BLE:result[" + String.valueOf(i) + "] = " + results.get(i).toString());
            }
        }

        @Override
        public void onScanFailed(int errorCode) {
            Log.d(TAG,"BLE:onScanFailed() Callback : errorCode=" + String.valueOf(errorCode));
        }
    };

スキャン結果1レコード分の定義は、SwitchBot.javaにクラスとしてまとめてあります。
コードの記事掲載は割愛しますが、コンストラクタの引数としてService dataのbyte列を渡すと、仕様とおりにビットマスクをしながらデータを抜き出し、温度のように符号、整数部、小数部が分割されているものは再構築してから、扱いやすいデータ型のメンバに格納しています。

これをMACアドレスをキーとして連想配列に格納しています。
1つのMACアドレスの蓄積数上限はSCANLOG_LIMIT_NUM に定義してあります。
上限に達すると古いものから削除しています。

MainActivity.java
    // スキャン結果の格納場所を確保
    Map<String, ArrayList<SwitchBot>> scanLog = new HashMap<String, ArrayList<SwitchBot>>();
    private static final int SCANLOG_LIMIT_NUM = 60 ;

MainActivity.java
    private void enterScanLog(SwitchBot inSwitchBotData) {
        ArrayList<SwitchBot> tempList = new ArrayList<SwitchBot>();
        if ( scanLog.containsKey(inSwitchBotData.macAddress) ) {
            Log.d(TAG, "From the 2nd Entry");

            //Limit the number of scan logs
            if ( scanLog.get(inSwitchBotData.macAddress).size() >= SCANLOG_LIMIT_NUM ) {
                scanLog.get(inSwitchBotData.macAddress).remove(0);
            }
            scanLog.get(inSwitchBotData.macAddress).add(inSwitchBotData);
        } else {
            Log.d(TAG, "1st Entry");

            scanLog.put(inSwitchBotData.macAddress, tempList);
            scanLog.get(inSwitchBotData.macAddress).add(inSwitchBotData);
        }
    }

蓄積データを利用した撮影判定

MACアドレスをキーに、湿度急上昇を判定しています。
この判定式は、無理矢理多めに履歴を使うようにした簡易的なものです。1つ過去のデータから上昇勾配をみたほうが簡単です。湿度以外にも多様な情報が利用できますし、履歴の数も変更できますので、自由にカスタマイズしてください。

MainActivity.java
    private String triggerMacAddr = "";
    private int HUMIDITY_THRESH = 5 ;
    private int HUMIDITY_JUDE_NUM = 1 ;
    private void shootingJudgmentOnHumidity (String inMacAddr, int newHumidity) {

        if ( scanLog.containsKey(inMacAddr) && (inMacAddr.equals(triggerMacAddr)) ) {

            if (scanLog.get(inMacAddr).size() > HUMIDITY_JUDE_NUM) {

                int averageHumidity=0;
                for (int i=0; i<HUMIDITY_JUDE_NUM; i++) {
                    averageHumidity += scanLog.get(inMacAddr).get( (scanLog.get(inMacAddr).size()-1)-i ).humidity;
                }
                averageHumidity = averageHumidity/HUMIDITY_JUDE_NUM;
                Log.d(TAG, "MAC:" + inMacAddr +", averageHumidity=" + String.valueOf(averageHumidity) + ", curHumidity=" + String.valueOf(newHumidity));

                if ( (newHumidity - averageHumidity) >= HUMIDITY_THRESH  ) {
                    Log.d(TAG, "shooting!");
                    stopPreview();
                    new TakePictureTask(mTakePictureTaskCallback).execute();
                }

            }
        }
    }

キーとなるMACアドレス「triggerMacAddr」は、こちらの記事と同様にSharedPreferencesを利用してプラグイン終了時の保存とプラグイン起動時の復旧をしています。
コードの掲載は割愛します。

WebUI関連

元となったプロジェクトのWebUIの下部に、「表の表示」と「グラフの表示(トリガーに設定したMACアドレスの湿度履歴)」を追加しています。index.htmlの変更点は以下だけです。JavaScriptが重要になります。

index.html
    <hr size=1>

    <br>
    <div id="scan_list"></div>    
    <br>
    <hr size=1>

    <br>
    <canvas id="chart_humidity"></canvas>
    <br>
    <hr size=1>

表を表示をするJavaScript

preview.jsに追加したupdateSwitchBotList()という関数で、データの取得と描画を動的に行っています。
コードの掲載は割愛します。

できあがった表示はこんなかんじ。

03_表.png

WebUIを表示する機器によっては表の更新頻度が高すぎてボタン押下を認識しにくいことがあります。パソコンブラウザで表示したときにそんな感じになりました。更新頻度を調節すると回避できるとおもいます。

グラフを表示するJavaScript

MITライセンスのChart.jsを利用しました。公式サイトから最新版を取得してjsフォルダーに置いています。適宜、更新して利用するようにしてください。

執筆時点最新版は Ver2.9.3 でした。Chart.jsのみを取得すればよいです。

preview.jsに追加したupdateChart()という関数で、データの取得と描画を動的に行っています。
コードの掲載は割愛します。

できあがった表示はこんなかんじ。

04_グラフ.png

横軸右端の最新データを0秒としたとき、何秒過去のデータかを「マイナス*秒」として表現していますが、私がChart.jsの使い方をマスターしていないので、すべてが等間隔にプロットされています。
この辺りを参考に、ご自身で修正したりカスタマイズして利用してください。

トリガーのMACアドレスを指定するJavaScript

preview.jsに追加したsetTrigger()という関数が、表中のボタンが押された時に動作し、設定を行っています。
コードの掲載は割愛します。

WebUIのために追加したコマンド

3つのコマンドを追加しています。

No コマンド名称 概要 パラメータ 戻り値
1 camera.getSwitchBotList 機器の一覧を最新スキャン結果つきで取得する なし SwitchBotのスキャン結果を最新値のみの配列で返す
2 camera.setTrigger 撮影トリガーとする機器をMACアドレスで指定する MACアドレス なし
3 camera.getHumidityList 指定した機器の湿度ログを取得する MACアドレス 時間の配列と湿度の配列

Webサーバーでのコマンド処理は以下の辺りを参照してください。
コード全ての掲載は割愛します。

WebServer.java
            } else if ( name.equals("camera.getSwitchBotList") ) {
                省略
            } else if ( name.equals("camera.setTrigger") ) {
                省略
            } else if ( name.equals("camera.getHumidityList") ) {
                省略

コマンド処理をするコールバック関数は以下の辺りを参照してください。
コード全ての掲載は割愛します。

MainActivity.java
        @Override
        public String getSwitchBotList() {
                省略
        }

        @Override
        public void setTriggerMac(String setMac) {
            triggerMacAddr=setMac;
        }

        @Override
        public String getHumidityList(String setMac) {
                省略
        }

その他の応用

温度や湿度以外の情報もとれているので、

  • 機器の増減(MACアドレスの増減)
  • 特定機器の電波強度変化(近づく、離れる)や「電波強度が○○以上の機器が△個以上」
  • 特定機器のバッテリー残量が変化

なども、なんらかのアクションをするトリガーになります。(特に上の2つは、一般的なBLEタグ(iBeaconを含む)でもとれる情報です。ただいまホットな「新型コロナウイルス接触確認アプリ(COCOA) COVID-19 Contact-Confirming Application」を動作させているスマホの数もカウントできるとおもわれます。UUIDが面倒だったかも。)

今回の事例では、湿度急上昇を検出するまでに結構なラグが生じるので(実質4秒間隔程度の取得、調子が良くても2秒間隔)、静止画の撮影をしても決定的瞬間を撮り逃がす可能性があります(用途によります)。こんなときは、ライブビューをバッファしておくと過去にさかのぼった映像を保存できます。

BLEのスキャンと共にwifiも使えているので、撮影するだけでなく

  • メールで通知する
  • SNSに通知(ツイート、メッセンジャー、LINE、etc)する
  • 各種クラウドサービス(IFTTT、AWS、GoogleDrive、etc)と連携する
  • 同一ネットワークの他機器(APIが公開されているもの)と連携する

などのアクションも行えます。

ほかにも、THETA本体から音を鳴らす、開発者モードならばUSB Host機器との連携もできます。
可能性は無限大ですね。

まとめ

私の不慣れもあって、いろいろハマりました。。。
が、これくらいの雛形があったら後に続く方々はどーにかなるかと思います。
SwitchBotシリーズにとどまらず、他のBLE機器連携にもチャレンジして頂きたいです。

ハマりポイントのTHETA固有事項は「Bluetooth関連のTHETA固有ノウハウ(要注意)」にまとめてありますが、それ以外ですと、一般的に「AndroidでBLEアプリの作成は鬼門」と言われていることにつきます。
何が鬼門かというと、Googleさんの提供するAPIが整理されておらず、実行することが同じでもやり方が複数あったり、GATTサーバーを介してBLEとBluetooth Classicの両立がなされているので、ドキュメントもアッチ見てこっち見てと散乱している状態なのが理由かななんて思います。

とはいえ、これでもう、なんとかなるんでないだろーか?

RICOH THETAプラグインパートナープログラムについて

THETAプラグインをご存じない方はこちらをご覧ください。
パートナープログラムへの登録方法はこちらにもまとめてあります。
QiitaのRICOH THETAプラグイン開発者コミュニティ TOPページ「About」に便利な記事リンク集もあります。
興味を持たれた方はTwitterのフォローとTHETAプラグイン開発者コミュニティ(Slack)への参加もよろしくおねがいします。

KA-2
普段は星空のある風景写真(なにかの表紙なったりもするレベル)を撮っているようです。 電子工作なんかも少しします。 ファーム屋(RTOS/制御屋/システム屋)、商品企画、星景写真撮影者(ASPJ正会員)、 肩書き色々。。。
iotlt
IoT縛りの勉強会です。 毎月イベントを実施しているので是非遊びに来てください! 登壇者を中心にQiitaでも情報発信していきます。 https://iotlt.connpass.com
https://iotlt.connpass.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした