1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

リモートID受信機を自作する。(パート3)

Last updated at Posted at 2023-03-11

前回のリモートID受信機を自作する(パート2)ではマップの表示は1つだけでしたが、今回は良く行くところのマップをいくつか用意し、その場所に行ったら、自動でそこのマップを表示させるようにして見たいと思います。
 ただ、SPIFFS領域が 2Mバイトしかないので、マップデータはかなり圧縮する必要があります。

まずは前回の記事と、国土地理院のマップのダウンロード方法を理解して置いて下さい。

マップの用意

今回は4つのマップを切り替えて表示させようと思います。
国土地理院のマップを4つ用意して下さい。

1つ目は、自宅周辺の地図
2つ目は、良く行く場所の地図
3つ目は、遠征場所の地図
4つ目は、日本全国の地図(これは↑のどこにも該当しない場合の表示用です)

サイズは縦横が同じになるようにして下さい。例えば(1000x1000)になるように。もっ大きくても大丈夫ですが、データサイズが大きくなるので程々に。
(ブラウザでの表示サイズは1000x1000にしたので、表示はこのサイズに圧縮されます。もっと多く表示させたい場合は、index.htmlファイルを修正して下さい。)
ダウンロードしたマップの緯度経度は、それぞれメモって置いて下さい。
国土地理院(日本).jpg

 ダウンロードしたファイルはPNG形式です。これはファイルサイズが大きいので、画像編集ソフトでJPGに変換して下さい。この時圧縮率を50%以下にして、ファイルサイズを300KB以下になるようにしましょう。(dataフォルダ内の容量の合計が2MB以下になるようにして下さい。)

ファイル名は以下のようにして下さい。
1つ目は、map0.jpg
2つ目は、map1.jpg
3つ目は、map2.jpg
4つ目は、map.jpg (日本地図だけ、番号なしです)

日本地図マークは用意しました。下図を「名前を付けて画像を保存」して下さい。
名前は「map.jpg」と「mark.png」としてください。

プログラム

前回と同様にVSCodeを立ち上げ、プロジェクトを作って置いて下さい。
上で用意したマップデータマークを[data]フォルダにドラッグ&ドロップしてコピーして下さい。
RID_REceiver.jpg

後は、前回までのやり方と同じです。

index.htmlは以下です。
maps = [] の所の緯度、経度は、それぞれダウンロードしたマップに合わせて、書き換えてください

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta http-equiv="content-type" charset="UTF-8">
<title>RID Receiver</title>
<style>
	body	{background-color:rgba(128,128,128,0.322);}
	h1		{font-size: 2em;color:rgb(156,5,5); background-color: whitesmoke;}
	h2		{font-size: 1.5em;color:black;}
	span	{color:blue;}
	.pos	{position:absolute; left: 0px; top:0px; width: 1000px; height: 1000px;}
</style>
</head>
<!-----------------------------------------HTML----------------------------------------->
<body>
	<h1>RID受信情報</h1>
	<h2>
		名前: <span style="color:rgb(216, 3, 3)" id="name"> </span><br>
		RSSI: <span id="rssi">0</span><br>
		Primary PHY: <span id="pPhy"></span><br> 
		Secondary PHY: <span id="sPhy"></span><br>
		Tx Power: <span id="pw"></span><span>dBm</span><br>
		Counter: <span id="cnt"></span><br>
		---------------------------------<br>
		製造番号: <span id="SN"></span><br>
		登録記号: <span id="RN"></span><br>
		機体種別: <span id="UA"></span><br>
		緯度: <span id="lat"></span><br>
		経度: <span id="lng"></span><br>
		速度: <span id="speed"></span><br>
		高度: <span id="alti"></span><br>
		方角: <span id="dir"></span><br>		
		時刻:<span id="time"></span><br>
	</h2>
	<div style="position:relative;">
		<img src="map0" id="map0" class="pos" style="visibility: visible">
	 	<img src="map1" id="map1" class="pos" style="visibility: hidden;">
	 	<img src="map2" id="map2" class="pos" style="visibility: hidden;">
		<img src="map" id="map" class="pos" style="visibility: hidden;">
		<canvas id="cvs" class="pos" width="1000px" height="1000px"></canvas>
		<div id="mark" style="position:absolute;">
			<img src="mark">
		</div>
	</div>
</body>
<!-------------------------------------JavaScript--------------------------------------->
<script>
	const w = 1000;			// マップの幅
	const h = 1000;			// マップの高さ

	let lng0,lat0,lng1,lat1;
	let curMap = 0;
	let x0=0,y0=0;
	const mk = document.getElementById('mark');							// マークのオブジェクト
	const ctx = document.getElementById('cvs').getContext('2d');		// キャンバスのコンテキスト

	let maps = [
		{ 
			"No": 0,
			"obj": document.getElementById('map0'),	// 自宅周辺の地図
			"lat0": 30.000000,   // <------ ここを書き換えてください
			"lng0": 100.000000,  // <------ ここを書き換えてください
			"lat1": 30.100000,   // <------ ここを書き換えてください
			"lng1": 100.100000   // <------ ここを書き換えてください
		},
		{
			"No": 1,
			"obj": document.getElementById('map1'),	// 良く行く場所の地図
			"lat0": 31.000000,   // <------ ここを書き換えてください
			"lng0": 101.000000,  // <------ ここを書き換えてください
		    "lat1": 31.100000,   // <------ ここを書き換えてください
			"lng1": 101.100000   // <------ ここを書き換えてください
		},
		{ 
			"No": 2,
			"obj": document.getElementById('map2'),	// 遠征先の地図
			"lat0": 32.000000,   // <------ ここを書き換えてください
			"lng0": 102.000000,  // <------ ここを書き換えてください
			"lat1": 32.100000,   // <------ ここを書き換えてください
			"lng1": 102.100000   // <------ ここを書き換えてください
		},	
		{ 
			"No": 3,
			"obj": document.getElementById('map'),	// 日本全体の地図(↑のどれにも当てはまら無い場合に表示する。これを一番最後に入れて下さい。)
			"lat0": 23.584126,
			"lng0": 122.058105,
			"lat1": 45.874712,
			"lng1": 149.523926
		}	
	];

	function drawLine(x,y){					        // 移動経過のラインを描画
		if(x0 != 0){
			ctx.beginPath();
     	    ctx.moveTo(x0, y0);
         	ctx.lineTo(x, y);									
			ctx.strokeStyle = '#FF0000';	        // ラインの色
  		    ctx.lineWidth = 1;				        // ラインの幅
			ctx.stroke();
		}
		x0 = x;
		y0 = y;
	}

	function setMap(n){								// マップ表示を切り替える
		for(let i=0; i < maps.length; i++){
			maps[i].obj.style.visibility = "hidden";
		}
		maps[n].obj.style.visibility = "visible";
		lng0 = maps[n].lng0;
		lat0 = maps[n].lat0;
		lng1 = maps[n].lng1;
		lat1 = maps[n].lat1;
		curMap = n;
		ctx.clearRect(0, 0, w, h);			// キャンバスのラインを消す
		x0 = 0;
	}
			
	function mapCheck(lng,lat){				// 経度、緯度から表示するマップを切り替える
		let m = maps.find(m => m.lng0 <= lng && m.lat0 <= lat && lng <= m.lng1 && lat <= m.lat1 )
		if(m !== undefined){
			if( m.No != curMap){
				setMap(m.No);
			}
		}
	}

	function InitWebSocket(){						    					// Websocketの初期設定
		websock = new WebSocket('ws://'+window.location.hostname+':81/');	// Websocket開始

		websock.onmessage = function(evt){									// データが送られて来た時の処理
			JSONobj = JSON.parse(evt.data);									// Json文字列を解析
			document.getElementById('name').innerHTML = JSONobj.name;
			document.getElementById('rssi').innerHTML = JSONobj.rssi;
			document.getElementById('cnt').innerHTML = JSONobj.cnt;
			document.getElementById('SN').innerHTML = JSONobj.SN;
			document.getElementById('RN').innerHTML = JSONobj.RN;
			document.getElementById('UA').innerHTML = JSONobj.UA;		
			document.getElementById('lat').innerHTML = JSONobj.lat;
			document.getElementById('lng').innerHTML = JSONobj.lng;
			document.getElementById('speed').innerHTML = JSONobj.speed;
			document.getElementById('alti').innerHTML = JSONobj.alti;
			document.getElementById('dir').innerHTML = JSONobj.dir;			
			document.getElementById('time').innerHTML = JSONobj.time;                
			document.getElementById('pPhy').innerHTML = JSONobj.pPhy;
			document.getElementById('sPhy').innerHTML = JSONobj.sPhy;
			document.getElementById('pw').innerHTML = JSONobj.pw;

			var lng = parseFloat(JSONobj.lng);
			var lat = parseFloat(JSONobj.lat);
			mapCheck(lng,lat);											// 現在地に合わせて、マップを変更する

            var x = Math.trunc( w * (lng - lng0) / (lng1 - lng0) );		// 経度をx座標に変換
			var y = h - Math.trunc( h * (lat - lat0) / (lat1 - lat0) );	// 緯度をy座標に変換

			if(curMap == maps.length-1) y += 36;						// 日本地図では何故か縦づれするので補正
			
			mk.style.left = x - 13 + 'px';								// マークの位置を変更(マークの左上角座標)	
			mk.style.top = y - 42 +'px';
			drawLine(x, y);												// 軌跡を描画
		}
	}

	window.onload = function() {								// ページが読み込まれたら最初に実行する
		InitWebSocket();
		setMap(0);												// 最初に表示するマップ番号を指定
	}

</script>
</html>

メインプログラムは以下です。

main.cpp
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <WebSocketsServer.h>
#include <SPIFFS.h>
#include <FS.h>

const char *ssid="RID_Receiver";
const char *pass="12345678";		   // パスワード(8Byte以上)
const IPAddress ip(192,168,5,1);	   // IPアドレス
const IPAddress subnet(255,255,255,0); // サブネットマスク

// ADタイプ
#define AD_TYPE_FLAG			0x01	// 発信モード
#define AD_TYPE_CMP_LOCAL_NAME	0x09	// 名前
#define AD_TYPE_TX_POWER		0x0A	// 送信出力値
#define AD_TYPE_SERVICE_DATA	0x16	// サービスデータ型 [16-bit UUID]

// メッセージ種別
#define MSG_TYPE_BASIC_ID		0x00	// 製造番号、登録記号
#define MSG_TYPE_LOCATION		0x10	// 位置情報
#define MSG_TYPE_AUTH			0x20	// 認証情報
#define MSG_TYPE_SELF_ID		0x30	// 操作目的 [文字列]
#define MSG_TYPE_SYSTEM			0x40	// リモートパイロットの位置情報
#define MSG_TYPE_OPERATOR_ID	0x50	// 操縦者ID [文字列]
#define MSG_TYPE_PACK  			0xF0	// パッケージ

// UAS ID Type
#define ID_TYPE_SerialNo		0x10	// 製造番号種別
#define ID_TYPE_ASSIGED_REG		0x20	// 登録番号種別
#define ID_TYPE_UUID			0x30	// UTM Assigned UUID

// 機体種別
#define UA_TYPE_NON						  0		// なし
#define UA_TYPE_AEROPLANE				  1		// 飛行機
#define UA_TYPE_HELICOPTER				  2		// ヘリコプター
#define UA_TYPE_GYROPLANE				  3		// ジャイロプレーン
#define UA_TYPE_HYBRID_LIFT				  4		// 垂直離陸固定翼機(ハイブリッドリフト)
#define UA_TYPE_ORINITHOPTER			  5		// 羽ばたき機(オルニソプター)
#define UA_TYPE_GLIDER					  6		// グライダー(滑空機)
#define UA_TYPE_KITE					  7		// カイト(凧)
#define UA_TYPE_FREE_BALLOON			  8		// 自由気球
#define UA_TYPE_CAPITIVE_BALLOON		  9		// 係留気球
#define UA_TYPE_AIRSHIP					  10	// 飛行船
#define UA_TYPE_FREE_FALL_PARACHUTE		  11	// パラシュート
#define UA_TYPE_ROCKET					  12	// ロケット
#define UA_TYPE_TETHERED_POWERED_AIRCRAFT 13	// テザー式動力航空機
#define UA_TYPE_GROUND_OBSTACLE			  14	// 地上障害物
#define UA_TYPE_OTHER					  15	// その他

// 位置情報
#define LOC_STA_NON			0x00	// 不明
#define LOC_STA_GROUND		0x10	// 地面
#define LOC_STA_AIRBRONE	0x20	// 飛行中
#define LOC_FLAG_HT			0x04	// Height Type 0:Above Takeoff 1:AGL(Above Ground Level)地面からの高度
#define LOC_FLAG_EW 		0x02	// Eeast/West Direction 0:<180 1>=180
#define LOC_FLAG_SM 		0x01	// Speed Multiplier 0:x0.25 1:x0.75

#pragma pack(push,1)	// データを1バイト単位に詰めて配置
typedef struct{
	uint8_t type;				// Location(MSG_TYPE_LOCATION)
	uint8_t status;				// 飛行中、方角E/W、速度倍率などの状態
	uint8_t dir;				// 方角
	uint8_t speed;				// 速度
	uint8_t Ver_speed;			// 垂直速度
	uint32_t lat;				// 緯度
	uint32_t lng;				// 経度							
	uint16_t Pressur_Altitude;	// 気圧高度
	uint16_t Geodetic_Altitude;	// GPS高度
	uint16_t Height;			// 地面からの高さ
	uint8_t V_H_Accuracy;		// 垂直、水平速度精度
	uint8_t B_S_Accuracy;		// 気圧、速度精度
	uint16_t timestamp;			// 現在時間の分以下小数点第1位までの秒数X10
	uint8_t T_Accuracy;			// 時間精度
	uint8_t resv3;
} location;

typedef struct{
	uint8_t type;			// 認証情報
	uint8_t Auth_Type;		// Authentication Message [認証情報]
	uint8_t page_count;		// Page0
	uint8_t Length;        	// headからのサイズ
	time_t timestamp_auth;	// 現在時刻(2019.1.1からの秒数)
	uint8_t auth_head;      // ヘッダ
	char auth_data[16];		// 認証データ
}AUTH;
#pragma pack(pop)

AsyncWebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);

uint32_t scanTime = 100; //In 10ms (1000ms)
BLEScan* pBLEScan;

String phy[3] ={"1M PHY","2M PHY","Coded PHY"};
String ua[16] ={"なし","飛行機","ヘリコプター","ジャイロプレーン","ハイブリッドリフト","羽ばたき機","グライダー","カイト(凧)","自由気球","係留気球","飛行船","パラシュート","ロケット"," テザー式動力航空機","地上障害物","その他"};
uint8_t msg_size = 25;
location *loc;
AUTH *auth;
uint8_t counter, ua_type;
char name[30],serNo[23],regNo[23];
char s_lat[20],s_lng[20],s_time[22],s_speed[20],s_alti[20], s_dir[6];
int8_t rssi;
uint8_t primary_phy, secondary_phy, tx_power;
String jsonTxt;
bool dataReady = false;
uint16_t ss=0;

void setJson(){
	jsonTxt ="{\"name\":\""		+ String(name) +	"\",";
	jsonTxt += "\"rssi\":\""	+ String(rssi) +	"\",";
 	jsonTxt += "\"cnt\":\""		+ String(counter) + "\",";	
	jsonTxt += "\"SN\":\""		+ String(serNo) +	"\",";
	jsonTxt += "\"RN\":\""		+ String(regNo) +	"\",";
	jsonTxt += "\"UA\":\""		+ ua[ua_type] +	"\",";
	jsonTxt += "\"lat\":\""		+ String(s_lat) + 	"\",";
	jsonTxt += "\"lng\":\""		+ String(s_lng) +	"\",";
	jsonTxt += "\"speed\":\""	+ String(s_speed) +	"\",";
	jsonTxt += "\"alti\":\""	+ String(s_alti) +	"\",";
	jsonTxt += "\"dir\":\""		+ String(s_dir) +	"\",";	
	jsonTxt += "\"time\":\""	+ String(s_time) +	"\",";
	jsonTxt += "\"pPhy\":\""	+ phy[primary_phy-1] +	"\",";
	jsonTxt += "\"sPhy\":\""	+ phy[secondary_phy-1] +"\",";
	jsonTxt += "\"pw\":\""		+ String(tx_power) +	"\"}";
	dataReady = true;
}

time_t tm2019;	// 1900年から2019年までの秒数
void get_tm2019(){
  struct tm stm;
  stm.tm_year = 2019 - 1900;
  stm.tm_mon  = 0;
  stm.tm_mday = 1;
  stm.tm_hour = 0;
  stm.tm_min  = 0;
  stm.tm_sec  = 0;
  tm2019 = mktime( &stm );
}

uint8_t *decode_msg(uint8_t *data){
	uint8_t *adr = data;	
	uint8_t type,size,n;
	uint8_t id_type,status,dir;
	float lat,lng,alti;
    uint16_t h,m,s;
	uint16_t speed;
	time_t tim;
	struct tm t; 
	
	type = data[0] & 0xF0;
	
	switch(type){
		case MSG_TYPE_PACK:							// パケットデータ
			msg_size = data[1];
			n 	 = data[2];
			adr  = &data[3];
			for(int i=0;i<n;i++){
				adr = decode_msg(adr);
			}
			break;
			
		case MSG_TYPE_BASIC_ID:
			id_type = data[1] & 0xF0;
			if(id_type == ID_TYPE_SerialNo){		// 製造番号
				memcpy(serNo, &data[2], 20);
				ua_type = data[1] & 0x0F;			// 機体種別
				adr += msg_size;
			}
			if(id_type == ID_TYPE_ASSIGED_REG){	    // 登録記号
				memcpy(regNo, &data[2], 20);
				adr += msg_size;
			}
			break;
		
		case MSG_TYPE_LOCATION:						// 位置情報
			loc = (location *)&data[0];

			status = loc->status; 
			lat = (float)loc->lat/10000000;	        // 緯度
			lng = (float)loc->lng/10000000;	        // 経度
			sprintf(s_lat,"%10.7f",lat);
			sprintf(s_lng,"%10.7f",lng);

			h = loc->timestamp%36000;				// 時刻
			m = h/600;
			s = (h%600)/10;
			ss = h%10;
			s_time[20] = '0' + ss;

			speed = loc->speed;						// 速度
			if(speed<254){
				if(status & LOC_FLAG_SM){
					speed = speed * 0.75 + 255/4;
				}else{
					speed /= 4; 
				}
			}
			sprintf(s_speed,"%4dm/s",speed);

			alti = loc->Geodetic_Altitude;	        // 高度
			alti = alti/2-1000;
			sprintf(s_alti,"%7.1fm",alti);
			
			dir = loc->dir;							// 方向
			if(status & LOC_FLAG_EW){
				dir += 180;
			}
			sprintf(s_dir,"%3d°", dir);

			adr += msg_size;
			break;

		case MSG_TYPE_AUTH:							// 認証情報
			auth = (AUTH *)&data[0];
			tim = auth->timestamp_auth + tm2019;
			t = *localtime(&tim);
			sprintf(s_time,"%04d/%02d/%02d %02d:%02d:%02d.%1d", t.tm_year + 1900, t.tm_mon +1 , t.tm_mday, (t.tm_hour + 9)%24, t.tm_min, t.tm_sec, ss);
			adr += msg_size;		
			break;

		default:
			adr += msg_size;
			break;			
	}
	return adr;
}

void decode_data(uint8_t *data, uint8_t size){
	uint8_t *adr, len, type;

	adr = data;
	while(adr < data+size){
		len = adr[0];
		if(len==0)break;

		type = adr[1];
		switch(type){
			case AD_TYPE_CMP_LOCAL_NAME:	// 名前データ
				memcpy(name, &adr[2], 28);
				break;

			case AD_TYPE_SERVICE_DATA:		// サービスデータ
				counter = adr[5];
				decode_msg(&adr[6]);		// メッセージをデコードする
				break;

			default:
				break;
		}
		adr += len+1;
	}
}

/**
* @brief extend adv report parameters
*/
//typedef struct {
//    esp_ble_gap_adv_type_t event_type;              /*!< extend advertising type */
//    uint8_t addr_type;                              /*!< extend advertising address type */
//    esp_bd_addr_t addr;                             /*!< extend advertising address */
//    esp_ble_gap_pri_phy_t primary_phy;              /*!< extend advertising primary phy */
//    esp_ble_gap_phy_t secondly_phy;                 /*!< extend advertising secondary phy */
//    uint8_t sid;                                    /*!< extend advertising sid */
//    uint8_t tx_power;                               /*!< extend advertising tx power */
//    int8_t rssi;                                    /*!< extend advertising rssi */
//    uint16_t per_adv_interval;                      /*!< periodic advertising interval */
//    uint8_t dir_addr_type;                          /*!< direct address type */
//    esp_bd_addr_t dir_addr;                         /*!< direct address */
//    esp_ble_gap_ext_adv_data_status_t data_status;  /*!< data type */
//    uint8_t adv_data_len;                           /*!< extend advertising data length */
//    uint8_t adv_data[251];                          /*!< extend advertising data */
//} esp_ble_gap_ext_adv_reprot_t;

// BLEがアドバタイジングデータを受信したとき呼ばれる
class MyBLEExtAdvertisingCallbacks: public BLEExtAdvertisingCallbacks {
	void onResult(esp_ble_gap_ext_adv_reprot_t report) {
		if(report.event_type & ESP_BLE_GAP_SET_EXT_ADV_PROP_LEGACY){
//        Serial.println("BLE4.2");
		}else{	// BLE 5.x の時
			rssi = report.rssi;
			primary_phy = report.primary_phy;
			secondary_phy = report.secondly_phy; 
			tx_power = report.tx_power;
			decode_data(report.adv_data, report.adv_data_len);
			setJson();
		}
	}
};

void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) {
	switch(type) {
		case WStype_DISCONNECTED:
			break;
		case WStype_CONNECTED:
			break;
		case WStype_TEXT:
			break;
		case WStype_BIN:
			break;
		case WStype_ERROR:			
		case WStype_FRAGMENT_TEXT_START:
		case WStype_FRAGMENT_BIN_START:
		case WStype_FRAGMENT:
		case WStype_FRAGMENT_FIN:
			break;
	}
}

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

  if(!SPIFFS.begin(true)){  // SPIFFSのセットアップ
		return;
  }
  get_tm2019(); // 2019年までの秒数を計算しておく

  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setExtendedScanCallback(new MyBLEExtAdvertisingCallbacks());	// BLE コールバックルーチンを指定
  pBLEScan->setExtScanParams();
  delay(1000);
  pBLEScan->startExtScan(scanTime, 1); // scan duration in n * 10ms, period - repeat after n seconds (period >= duration)

  WiFi.softAP(ssid,pass);
  WiFi.softAPConfig(ip,ip,subnet);

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/index.html");
  });
  
	server.on("/map", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/map.jpg", "image/jpg");
  });
	server.on("/map0", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/map0.jpg", "image/jpg");
  });
	server.on("/map1", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/map1.jpg", "image/jpg");
  });
	server.on("/map2", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/map2.jpg", "image/jpg");
  });

  server.on("/mark", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/mark.png", "image/png");
  });
  server.begin();
	
  webSocket.begin();
  webSocket.onEvent(webSocketEvent);

}

void loop() {
	webSocket.loop();
	if(dataReady){
		webSocket.broadcastTXT(jsonTxt);	// WebSocketでデータ送信
		dataReady = false;
	}
}

使い方は前回を参考にして下さい。
以上です。

1
1
9

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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?