2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ProcessingでESP32の内部データをWifi受信してリアルタイムグラフ化する

Posted at

はじめに

Arduinoなどのマイコンボードで開発していると接続したセンサーや内部計算情報を可視化したい場合が多々ある。

数値の羅列では良く分からないし、CSV出力してEXCELなどで解析するには手数がかかるし、ArduinoのSerial Plotでは思うように表示できなかったりする。

ここでは、図のような構成でESP32 Dev ModuleのWifi機能を使ってPCに送信してProcessingでリアルタイムグラフ化するための雛形を作成してみた。

同様の記事は散見するが、ここでは実際にESP32にデバイスを接続するのでは無くて、応用しやすいようにダミーデータを利用している。

「ESP32 Dev Module」は、プログラムが固定され、電源供給ができるならUSBケーブルを介せずにWifi経由でデータの送受信が出来るので便利である。
fig1.png

実際のテストでは、写真のようにプログラム書き込みと電源供給のためのUSBケーブルを接続するだけです。
IMG_2398.JPG

使ったもの

  • PC : Windows10、Processing 4.0.1
  • マイコン : ESP32 Dev Module
  • Wifi : 自宅無線LANルータ

手順1:ESP32のアドレスの確認

以下のプログラムにてESP32のアドレス確認を行った。(他の方法でも可)

ESP32 アドレス確認用プログラム
//
// ESP32 Wifi Address Check
//
#include <WiFi.h>

// ------------------------------------------------
// Wifi
const char* ssid     = "SSID";              // ルータのSSIDに書き換えてください
const char* password = "pass word";         // ルータのパスワードに書き換えてください
WiFiServer server( 5000);                   // ポート番号5000でサーバーとして使用する
// ------------------------------------------------

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

    // ------- Wifi ------------------------
    Serial.println();
    Serial.println();
    Serial.print( "Connecting to ");
    Serial.println( ssid);
    WiFi.begin( ssid, password);              // ルータに接続
    while( WiFi.status() != WL_CONNECTED) {
        delay( 500);
        Serial.print(".");
    }

    // 接続が完了したら以下の内容を表示
    Serial.println( "");
    Serial.println( "WiFi connected.");
    Serial.println( "IP address: ");
    Serial.println( WiFi.localIP());          // 自身のIPアドレスを表示

    Serial.println( "Stop");
    while( 1) {
        ;
    }
}

void loop()
{
  // put your main code here, to run repeatedly:
}

手順2:ESP32のプログラム

Processingに送信するデータは、センサーが4つ繋がっていることを想定して、経過時間と共に

  • 経過時間 : 実数型 1
  • ダミーデータ : 実数型 2
  • ダミーデータ : 整数型 1

とした。

リストを見てもらえば分かるかと思いますが、データはSin/Cos/乱数にて適当な値を生成しています。
また、各データの生成時間間隔も適当な値にしています。

重要なのはProcessingへのデータ送信間隔で、送信間隔により1回に受信される文字列の長さが異なるようなので受信側(Processing)で多少工夫しました。(後述)

ここでは、100msecの間隔で送信し、定義した動作時間が過ぎると終了コード(0x00)を送信して停止します。

ESP32側のダミーデータ送信プログラム
//
// ESP32 -> PC Wifi
//
// ------------------------------------------------
#include <WiFi.h>

// ------------------------------------------------
// Wifi
const char* ssid     = "SSID";              // SSIDに書き換えてください
const char* password = "pass word";         // パスワードに書き換えてください
WiFiServer server( 5000);                   // ポート番号5000でサーバーとして使用する

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

    randomSeed( 1234);                      // 乱数の種(適当)

    // ------- Wifi ------------------------
    Serial.println();
    Serial.print( "Connecting to ");
    Serial.println( ssid);
    WiFi.begin( ssid, password);              // アクセスポイントに接続

    // 接続が完了するまで待つ
    while( WiFi.status() != WL_CONNECTED) {
        delay( 500);
        Serial.print(".");
    }

    Serial.println( "");
    Serial.println( "WiFi connected.");
    Serial.println( "IP address: ");
    Serial.println( WiFi.localIP());        // 自身(ESP32)のIPアドレスを表示

    // 開始
    server.begin();
}

#define MOVE_TIME           10000       // 動作時間
#define SEND_INTERVAL       100         // 送信間隔(msec)
#define SENSOR1_INTERVAL    5           // data1 測定間隔(msec) float型ダミーデータ:適当
#define SENSOR2_INTERVAL    5           // data2 測定間隔(msec) float型ダミーデータ:適当
#define SENSOR3_INTERVAL    10          // data3 測定間隔(msec) int型ダミーデータ:適当
#define SENSOR4_INTERVAL    20          // data4 測定間隔(msec) int型ダミーデータ:適当

void loop()
{
    char buff[ 128];
    uint32_t movetime;
    uint32_t dt0, dt1, dt2, dt3, dt4;
    float tt;
    float data1, data2, inctt;
    int data3, data4;

    Serial.println( "Waiting client connection ...");

    WiFiClient client = server.available();

    if( client) {
        Serial.println( "New Client");
        Serial.println( "Start ...");

        data1 = data2 = 0.0;                // floatデータ初期化
        data3 = data4 = 0;                  // intデータ初期化

        // タイマー初期化
        movetime = millis();
        dt0 = millis();
        dt1 = millis();
        dt2 = millis();
        dt3 = millis();
        dt4 = millis();
        tt = 0.0;                           // 経過時間初期化
        inctt = 1.0 / SEND_INTERVAL;        // 経過時間増分

        while( 1) {
            if( millis() - dt0 >= SEND_INTERVAL) {
                if( client.connected()) {
                    sprintf( buff, "%.3f,%.3f,%.3f,%d,%d\n", tt, data1, data2, data3, data4);
                    client.print( buff);
                    Serial.print( buff);
                    tt += inctt;            // 経過時間更新
                }
                else {
                    break;
                }
                dt0 = millis();
            }
            if( millis() - dt1 >= SENSOR1_INTERVAL) {
                data1 = dev_1( tt);                          // float型のダミーデータ入力
                dt1 = millis();
            }
            if( millis() - dt2 >= SENSOR2_INTERVAL) {
                data2 = dev_2( tt);                          // float型のダミーデータ入力
                dt2 = millis();
            }
            if( millis() - dt3 >= SENSOR3_INTERVAL) {
                data3 = dev_3();                             // int型のダミーデータ入力
                dt3 = millis();
            }
            if( millis() - dt4 >= SENSOR4_INTERVAL) {
                data4 = dev_4();                            // int型のダミーデータ入力
                dt4 = millis();
            }

            // ------>
            // 無限動作させる場合は、このチェックを外す
            if( millis() - movetime >= MOVE_TIME) {       // 動作時間チェック
                break;
            }
            // <------
        }

        // ------>
        // 無限動作させる場合は、この処理を外す
        byte term = 0x00;               // データの終わり
        client.write( term);            // データの終わりを送信
        Serial.println( "Send end.");
        delay( 1000);                   // Processing側の終了処理が遅いので少し待つ
        client.stop();                  // 接続を切断
        Serial.println( "Stop.");

        // 無限ループ
        while( 1) { ;}
        // <------
    }
}

// float型のダミー関数(適当)
float dev_1( float tt)
{
    float s1 = 3.0 * sin( TWO_PI * 0.05 * tt);
    float s2 = 1.5 * sin( TWO_PI * 1.0 * tt);

    return s1 + s2;
}

// float型のダミー関数(適当)
float dev_2( float tt)
{
    float fval = 2.5 * cos( TWO_PI * 0.1 * tt);

    return fval;
}

// int型のダミー関数(適当)
int dev_3( void)
{
    static int cnt = 0;
    static int ival = 0;

    if( ( cnt % 50) == 0) {
        if( ival == -5) ival = 15;
        else            ival = -5;
    }
    cnt++;

    return ival;
}

// int型のダミー関数(適当)
int dev_4( void)
{
    long randval = random( -20, -5);
    int ival = (int)randval;

    return ival;
}

手順3:Processingで受信確認(単純受信)

起動方法

  1. ESP32側の送信プログラムを実行します
  2. クライアント(Processing)の接続待ちとなります
  3. PC側でProcessing起動し以下の単純受信リストを実行します
  4. 受信文字列が表示されます
Processing側の単純受信プログラム
import processing.net.*; 

Client ESP32_Client;
int port = 5000;
String ipaddress = "192.168.xx.xx";  // ESP32のアドレス
String inString;                     // 受信文字列
int n = 0;

void setup()
{
    ESP32_Client = new Client( this, ipaddress, port); 
    println( "Start ...");
}

void draw()
{
    if( ESP32_Client.available() > 0) { 
        inString = ESP32_Client.readString();
        print( n, " : ", inString);
        n++;

        if( inString.indexOf( 0x00) != -1) {    // 受信データの終わり(送信側では0x00を送信)
            println( "Data end detect.");
            exit();
        }
    }
}

単純受信の結果

実行して「inString」の受信文字列を表示すると、こんな感じになります。
0 : 0.000,85.135,-27.474,8,-6
1 : 0.010,80.405,-26.526,14,-10
2 : 0.020,82.297,-9.474,1,-15
3 : 0.030,71.892,-23.684,3,-15
 ・ ・ ・ ・

送信間隔を変えた場合の結果

一見、正常に受信できているかと思われますが、ESP32側の送信間隔を
100msec ⇒ 10msec
に変更すると、「inString」内の受信文字列は送信単位では無いようです。
このことは、Processingの内部処理が遅いものと推測されます。

#define SEND_INTERVAL       10         // 送信間隔(msec)

0 : 0.000,87.973,-21.789,0,0    <- 1回目の受信文字列
1 : 0.100,78.514,-23.684,5,-6
0.200,66.216,-28.421,5,-6
0.300,71.892,-7.579,11,-18
・ ・ ・
0.600,68.108,-18.000,-3,-11
0.700,70.946,-16.105,-5,-19
2 : 0.800,67.162,-28.421,-1,-19  <- 2回目の受信文字列
0.900,80.405,-13.263,5,-20
1.000,73.784,-15.158,1,-20
1.100,76.622,-28.421,1,-16
1.200,75.676,-12.316,14,-16
・ ・ ・
1.600,72.838,-17.053,3,-12
1.700,74.730,-28.421,-4,-6
3 : 1.800,76.622,-22.737,9,-6  <- 3回目の受信文字列
1.900,83.243,-14.211,0,-10
2.000,83.243,-13.263,13,-10
2.100,87.973,-28.421,10,-10
2.200,87.027,-22.737,10,-10
4 : 2.300,73.784,-26.526,9,-17  <- 4回目の受信文字列
2.400,87.973,-12.316,9,-17
2.500,71.892,-5.684,-4,-6
・ ・ ・

手順4:Processingで受信(改良)

上記問題点を改良して、受信データをCSVファイルに数値化保存して表示するようにしたのが以下のリストです。

Processing側の改良した受信プログラム
//
//    ESP32 --- Wifi --> PC
//
import processing.net.*; 

Client ESP32_Client;
PrintWriter CSV_File;

int port = 5000;
String ipaddress = "192.168.xx.xx";  // ESP32のアドレス

int n;
float fdata1, fdata2;
int idata1, idata2;
String items = "n,fdata1,fata2,idata1,idata2\n";  // csv データ名
String inString;                                  // 受信文字列
String ss;                                        // 文字バッファ
boolean flg;

void setup()
{
    ESP32_Client = new Client( this, ipaddress, port); 
    CSV_File = createWriter( "ESP32_data.csv");
    CSV_File.print( items);
    ss = "";
    flg = false;
    println( "Start ...");
}

void draw()
{
    if( ESP32_Client.available() > 0) { 
        inString = ESP32_Client.readString();              // バッファに読込む
        if( inString.indexOf( 0x00) > 0) {                 // 終了コードが含まれる文字列か?(送信側では0x00を送信)
            flg = true;                                    // 最終文字列であるというフラグを立てる
            String[] sTemp = split( inString, char(0x00)); // 終端コードを除去
            inString = sTemp[ 0];                          // 入力文字列修正
        }
        CSV_File.print( inString);                        // CSVファイルに出力
    
        ss += inString;                                   // 受信文字列(文字列に'\n'が出て来るまで加算)
        int ll  = ss.length();                            // 受信文字数
        int pos = ss.indexOf( "\n", ll-1);                // 最後の文字に'\n'があるか?
                                                          
        if( pos != -1) {                                 // 無い場合は次を読み込む
            String[] ls = split( ss, "\n");              // '\n'で文字列を分離
            for( int j = 0; j < ls.length - 1; j++) {    // 文字列を更に','で分離
                String[] sval = split( ls[ j], ",");
                // 数値化
                n = int( sval[ 0]);
                fdata1 = float( sval[ 1]);
                fdata2 = float( sval[ 2]);
                idata1  = int(  sval[ 3]);
                idata2  = int(  sval[ 4]);
                println( n, fdata1, fdata2, idata1, idata2);
            }
            ss = "";                                    // 文字列バッファの初期化
        }

        // CSVファイルの最終処理
        if( flg) {
            CSV_File.flush();
            CSV_File.close();
            println( "Data end detect.");
            exit();
        }
    }
}

受信データのグラフ化

Processingで受信データが正常にできるようになったので、受信データのグラフ化を行います。
リアルタイムグラフ化にあたっては以下のサイトを参考にさせて頂きました。
(クラス部分は丸パクリです。 ありがとうございました)

グラフを流れるように表示するには、表示を更新する度に

  • 配列内データを1つ前にシフト
  • 新たなデータを配列の最後に代入

の手順で行っています。
以下のリストでは応用しやすいように、floatデータとintデータを別々のグラフにしています。

Processing リアルタイムグラフ プログラム
//
//    ESP32 --- Wifi --> PC
//        ESP32 内部情報のグラフ化(ダミーデータ)
//
import processing.net.*; 

Client ESP32_Client;
Data_Monitor1 Data_Graph1;
Data_Monitor2 Data_Graph2;
PrintWriter CSV_File;

int port = 5000;                     // 接続ポート
String ipaddress = "192.168.xx.xx";  // ESP32のアドレス

// 表示色の定義
color BLACK = #000000;
color RED   = #FF0000;
color GREEN = #00FF00;
color BLUE  = #0000FF;
color YELLOW = #FFFF00;
color MAZENTA = #FF00FF;
color LIGHTBLUE = #00FFFF;
color WHITE = #FFFFFF;
color GRAY1 = #4D4D4D;

float tt;
float fdata1, fdata2;
int idata1, idata2;
String items = "Time,fdata1,fdata2,idata1,idata2\n";    // csvデータ項目名称
String inString;                                        // 受信文字列
String ss;                                              // 文字バッファ
boolean flg;                                            // 終了フラグ

void setup() 
{
    size( 750, 640);
    ESP32_Client = new Client( this, ipaddress, port); 
  
    frameRate( 30);
    smooth();
    Data_Graph1 = new Data_Monitor1( "Float Data",  80, 50, 600, 250);
    Data_Graph2 = new Data_Monitor2( "Int Data",  80, 350, 600, 250);

    CSV_File = createWriter( "ESP32_data.csv");
    CSV_File.print( items);
    
    ss = "";            // 文字列バッファの初期化
    flg = false;        // データの終わり判定フラグの初期化
    println( "Start....");
}

void draw() 
{
    if( ESP32_Client.available() > 0) { 
        inString = ESP32_Client.readString();              // バッファから読込む
        if( inString.indexOf( 0x00) > 0) {                 // 終了コードが含まれる文字列か?(送信側では0x00を送信)
            flg = true;                                    // 最終文字列であるというフラグを立てる
            String[] sTemp = split( inString, char(0x00)); // 終端コードを除去
            inString = sTemp[ 0];                          // 入力文字列修正
        }
        CSV_File.print( inString);                        // CSVファイルに出力
    
        ss += inString;                                   // 受信文字列(文字列に'\n'が出て来るまで加算)
        int ll  = ss.length();                            // 受信文字数
        int pos = ss.indexOf( "\n", ll-1);                // 最後の文字に'\n'があるか?
                                                          // 無い場合は次を読み込む
        if( pos != -1) {
            String[] ls = split( ss, "\n");              // '\n'で文字列を分離
            for( int j = 0; j < ls.length - 1; j++) {    // 文字列を更に','で分離
                String[] sval = split( ls[ j], ",");
                // 数値に変換
                tt = float( sval[ 0]);                   // 経過時間
                fdata1 = float( sval[ 1]);               // float型ダミーデータ 
                fdata2 = float( sval[ 2]);               // float型ダミーデータ
                idata1 = int( sval[ 3]);                 // int型ダミーデータ
                idata2 = int( sval[ 4]);                 // int型ダミーデータ
        
                background( GRAY1);
                fill( GREEN);
                textSize( 20);
                text( "Mouse left button Click, then exit", 350, 640);
                Data_Graph1.graphDraw( fdata1, fdata2);    // floatデータのグラフ
                Data_Graph2.graphDraw( idata1, idata2);    // intデータのグラフ
            }
            ss = "";    // 文字列バッファの初期化
        }

        // 終了コード検出 CSVファイルの最終処理
        if( flg) {
            CSV_File.flush();
            CSV_File.close();

            println( "End code detected. Exit.");
            exit(); 
        }
    }
}

//
// グラフ上でマウスの左ボタンが押された場合強制終了する
// 
void mousePressed()
{
    CSV_File.flush();
    CSV_File.close();
    
    println( "Mouse button clicked. Exit.");
    exit(); 
}

// floatデータのグラフ
class Data_Monitor1 
{
    String TITLE;
    int X_POSITION, Y_POSITION;
    int X_LENGTH, Y_LENGTH;
    float[] fval1, fval2;
    float maxRange;

    Data_Monitor1( String _TITLE, int _X_POSITION, int _Y_POSITION, int _X_LENGTH, int _Y_LENGTH) 
    {
        TITLE = _TITLE;
        X_POSITION = _X_POSITION;
        Y_POSITION = _Y_POSITION;
        X_LENGTH   = _X_LENGTH;
        Y_LENGTH   = _Y_LENGTH;
        fval1 = new float[ X_LENGTH];
        fval2 = new float[ X_LENGTH];
        for( int i = 0; i < X_LENGTH; i++) {
            fval1[ i] = 0;
            fval2[ i] = 0;
        }
    }

    void graphDraw( float fdata1, float fdata2) 
    {
      // 一つ前にシフト
      for( int i = 0; i < X_LENGTH - 1; i++) {
          fval1[ i] = fval1[ i + 1];
          fval2[ i] = fval2[ i + 1];
      }
      // 配列の最後に最新データを格納
      fval1[ X_LENGTH - 1] = fdata1;
      fval2[ X_LENGTH - 1] = fdata2;
      
      // 最大値の更新
      maxRange = 1;    // 暫定値
      for( int i = 0; i < X_LENGTH - 1; i++) {
          maxRange = ( abs( fval1[ i]) > maxRange ? abs( fval1[ i]) : maxRange);
          maxRange = ( abs( fval2[ i]) > maxRange ? abs( fval2[ i]) : maxRange);
      }
      
      // 座標系退避
      pushMatrix();
      // 原点移動
      translate( X_POSITION, Y_POSITION);
      fill( BLACK);
      stroke( WHITE);
      strokeWeight( 1);
      rect( 0, 0, X_LENGTH, Y_LENGTH);
      line( 0, Y_LENGTH / 2, X_LENGTH, Y_LENGTH / 2);

      textSize( 20);
      fill( WHITE);
      textAlign( LEFT, BOTTOM);
      text( TITLE, 20, -5);

      textSize( 18);
      textAlign( RIGHT);
      // Y軸目盛り
      text( 0, -5, Y_LENGTH / 2 + 7);
      text( nf( maxRange, 0, 1), -10, 18);
      text( nf( -1 * maxRange, 0, 1), -10, Y_LENGTH);

      // 受信データ表示
      fill( WHITE);
      text( "fdata1 :", 200, -5);
      text( nf( fdata1, 3, 1), 250, -5);
      
      fill( WHITE);
      text( "fdata2 :", 400, -5);
      text( nf( fdata2, 3, 1), 450, -5);

      translate( 0, Y_LENGTH / 2);
      scale( 1, -1);
      strokeWeight( 1);

      // 波形描画(直線補間)
      for( int i = 0; i < X_LENGTH - 1; i++) {
          stroke( MAZENTA);
          line( i, fval1[ i] * ( Y_LENGTH / 2) / maxRange, i + 1, fval1[ i + 1] * ( Y_LENGTH / 2) / maxRange);
          stroke( YELLOW);
          line( i, fval2[ i] * ( Y_LENGTH / 2) / maxRange, i + 1, fval2[ i + 1] * ( Y_LENGTH / 2) / maxRange);
      }
      // 座標系復帰
      popMatrix();
    }
}

// intデータのグラフ
class Data_Monitor2 
{
    String TITLE;
    int X_POSITION, Y_POSITION;
    int X_LENGTH, Y_LENGTH;
    int[] ival1, ival2;
    float maxRange;

    Data_Monitor2( String _TITLE, int _X_POSITION, int _Y_POSITION, int _X_LENGTH, int _Y_LENGTH) 
    {
        TITLE = _TITLE;
        X_POSITION = _X_POSITION;
        Y_POSITION = _Y_POSITION;
        X_LENGTH   = _X_LENGTH;
        Y_LENGTH   = _Y_LENGTH;
        ival1 = new int[ X_LENGTH];
        ival2 = new int[ X_LENGTH];
        for( int i = 0; i < X_LENGTH; i++) {
            ival1[ i] = 0;
            ival2[ i] = 0;
        }
    }

    void graphDraw( int idata1, int idata2) 
    {
      // 一つ前にシフト
      for( int i = 0; i < X_LENGTH - 1; i++) {
          ival1[ i] = ival1[ i + 1];
          ival2[ i] = ival2[ i + 1];
      }
      // 配列の最後に最新データを格納
      ival1[ X_LENGTH - 1] = idata1;
      ival2[ X_LENGTH - 1] = idata2;
      
      // 最大値の更新
      maxRange = 30;    // 暫定値
      for( int i = 0; i < X_LENGTH - 1; i++) {
          maxRange = ( abs( ival1[ i]) > maxRange ? abs( ival1[ i]) : maxRange);
          maxRange = ( abs( ival2[ i]) > maxRange ? abs( ival2[ i]) : maxRange);
      }
      
      // 座標系退避
      pushMatrix();
      // 原点移動
      translate( X_POSITION, Y_POSITION);
      fill( BLACK);
      stroke( WHITE);
      strokeWeight( 1);
      rect( 0, 0, X_LENGTH, Y_LENGTH);
      line( 0, Y_LENGTH / 2, X_LENGTH, Y_LENGTH / 2);

      textSize( 20);
      fill( WHITE);
      textAlign( LEFT, BOTTOM);
      text( TITLE, 20, -5);

      textSize( 18);
      textAlign( RIGHT);
      // Y軸目盛り
      text( 0, -5, Y_LENGTH / 2 + 7);
      text( nf( maxRange, 0, 1), -10, 18);
      text( nf( -1 * maxRange, 0, 1), -10, Y_LENGTH);

      // 受信データ表示
      fill( WHITE);
      text( "idata1 :", 200, -5);
      text( nf( idata1, 3, 1), 250, -5);
      fill( WHITE);
      text( "idata2 :", 400, -5);
      text( nf( idata2, 3, 1), 450, -5);

      translate( 0, Y_LENGTH / 2);
      scale( 1, -1);
      strokeWeight( 1);

      // 波形描画(直線補間)
      for( int i = 0; i < X_LENGTH - 1; i++) {
          stroke( GREEN);
          line( i, ival1[ i] * ( Y_LENGTH / 2) / maxRange, i + 1, ival1[ i + 1] * ( Y_LENGTH / 2) / maxRange);
          stroke( LIGHTBLUE);
          line( i, ival2[ i] * ( Y_LENGTH / 2) / maxRange, i + 1, ival2[ i + 1] * ( Y_LENGTH / 2) / maxRange);
      }
      // 座標系復帰
      popMatrix();
    }
}

結果

wave_data2.gif

注記

  • 途中で止める場合は、グラフ内でマウスボタンを押すと終了します
  • ESP32側のリスト内コメントにあるように、送信側を無限ループとした場合はProcessing側で停止・起動を繰り返し行うことが出来ます
  • 但し、適当な時間で停止しないと何時までもCSVファイルに書き出しますので注意が必要です。

あとがき

Wifiによるソケット通信は初めてなのですが、最初の頃データが安定して受信できないのでバイナリ通信も考える必要があるのかと思ったのですが、原因は文字列通信に問題があるのでは無くてProcessing側の処理速度にあるようです?

2
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?