13
12

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 3 years have passed since last update.

スマートホームスキルを作る(2):いよいよスマートホームスキルを作成する

Last updated at Posted at 2018-11-21

前回黒豆を使ったRESTfulサーバを立ち上げました。

スマートホームスキルを作る(1):黒豆を操作するRESTful API環境を構築する

今回はそれをAlexaスマートホームスキルから呼び出すようにして、「アレクサ、寝室の照明を消して」を実現しようと思います。

スマートスキル ⇔ Cognito ⇔ Lambda を行ったり来たりします。
結構長丁場ですので、覚悟の上読み進めてください!

参考URLを挙げておきます。
https://developer.amazon.com/ja/docs/smarthome/understand-the-smart-home-skill-api.html

OpenID Connect/OAuth2サーバを立ち上げる

Alexaのスマートホームスキルでは、Alexaのアカウントリンクが必須になっています。ですので、まずは、アカウントリンクのためにOpenID Connectサーバを立ち上げます。
今回はOpenID Connectサーバとして、AWS Cognitoを採用しました。

細かくは割愛しますが、以下が参考になります。

AWS CognitoにGoogleとLINEアカウントを連携させる

AWS Cognitoのユーザプールが作成されていると仮定して以降進めます。

Aelxaスキルからの接続を受け付けるために、AWS Cognitoにアプリクライアントを作成します。
OAuthフローとして、「Authorization Code Grant」を使うため、アプリクライアント作成時の「クライアントシークレットの生成」のチェックボックスをOnにします。
作成が完了すると、アプリクライアントIDとアプリクライアントのシークレットが生成されます。この値は後で使います。

また、アプリの統合のドメイン名にあるドメインのプレフィックスに適当なドメイン名を指定しておきます。

https://【ドメイン名】.auth.ap-northeast-1.amazoncognito.com

というURLがこのユーザプールに割り当てられました。

とりあえず、AWS Cognitoはここまでにして、またあとで戻ってきます。

ちなみに、AWS CognitoでOpenID Connectサーバを立ち上げるのが面倒、という場合は、ぜひ以下の利用も考えてみてください。

なんちゃってOAuth2/OpenID Connectサーバを自作する

Alexaスマートホームスキルの作成

それでは早速スマートホームスキルづくりを始めていきます。
まずはAlexa Skills Kit開発者コンソールを開きます。

alexa developer console
https://developer.amazon.com/alexa/console/ask

image.png

まずは、スキルの作成ボタンを押下。

image.png

スキル名に適当な名前を付けます。例えば、「テストスマートホーム」とします。
スキルに追加するモデルを選択、のところでは、「スマートホーム」を選択します。

image.png

まず表示されるのが、スマートホームのサービスエンドポイントの指定です。(普通のカスタムスキルの場合はインテントの作成に移るのですが、スマートホームスキルではそれがないんです)
とりあえず、スキルIDを覚えておきます。クリップボードにコピー のリンクをクリックすると、クリップボードにコピーされるので便利です。

スマートホームスキル用のLambdaの作成

これからちょっと、AWS管理WebコンソールのLambdaとAlexa Skills Kit開発者コンソールを行ったり来たりします。

AWS管理Webコンソール から、Lambda関数を作成します。
ここで注意が必要なのですが、現時点(2018/11)では、スマートホーム用のLambdaは東京リージョンではなくオレゴンリージョンに作成する必要があります。
したがって、コンソールは以下のURLになります。

image.png

適当な名前を付けます。たとえば、「test-smarthome」とします。
作成した後、いつものAPI Gatewayと接続するときとは違ったことをします。それは、左側にあるトリガーの選択で「Alexa Smart Home」を選択するのです。

image.png

すると、少し下の方に、スキルIDを入力する欄が現れます。
これにさきほど覚えておいたスキルIDを入力して、最後に右上の「保存」ボタンを押下します。
さらに、「保存」ボタンの上に表示されているARNを覚えておきます。
こんな感じです。

arn:aws:lambda:us-west-2:XXXXXXXXXXXX:function:test-smarthome

スマートホームスキルの設定

Alexa Skills Kit開発者コンソールに戻ります。

image.png

さきほど覚えたLambdaのARNを、デフォルトのエンドポイントに入力します。
さらに、極東のチェックボックスをOnにしたのち、同じようにLambdaのARNを入力します。
現時点では、東京リージョンには対応していないだけで、いつかは東京リージョンに対応してくれることを心待ちにしておきましょう。

以上で、スマートホームスキルとLambdaがつながりました。

次の設定は、アカウントリンクです。

image.png

今度は、AWS CognitoとAlexa Skills Kit開発者コンソールを行ったり来たりします。

まず、認証画面のURIです。これは、AWS Cognitoの認証エンドポイントに相当します。

https://【ドメイン名】.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize

です。
次が、アクセストークンのURIです。これは、トークンエンドポイントに相当します。

https://【ドメイン名】.auth.ap-northeast-1.amazoncognito.com/oauth2/token

です。
他も同様に、クライアントIDはAWS CognitoのアプリクライアントID、クライアントシークレットは、アプリクライアントのシークレットです。
クライアントの認可方法は、HTTP Basic認証(推奨)のままでよいです。
スコープには、「openid」を指定します。
あとは、右上の「保存」ボタンを押下します。

リダイレクト先のURLが3つほど書かれています。
これを、AWS Cognitoのアプリクライアントの設定の、コールバックURLに指定しておきます。3つのURLをカンマ「,」区切りで繋げて書きます。

image.png

ついでに、アプリクライアントの他の設定は、有効なIDプロバイダとして、「Cognito User Pool」を選択し、許可されているOAuthフローには、「Authorization code grant」を選択します。許可されているOAuthスコープには、さきほどAlexaのアカウントリンクにも指定した「openid」を指定します。
最後に、「変更の保存」ボタンを押下して保存しておきます。

ここでちょっと忘れないうちに、ユーザアカウントを1つ作っておきましょう。
左上のユーザとグループを選択して、ユーザの作成ボタンを押下します。

image.png

すると、入力したメールアドレスに仮パスワードが書かれたメールアドレスが届いているはずですので、確認しておきます。後で使います。

Lambdaの実装

それでは、今度はLambdaの実装をします。
AlexaスマートホームスキルAPIで定義されたリクエストを受信して、レスポンスを返します。

(参考情報)
スマートホームスキルAPIのメッセージリファレンス

Lambdaでは、外部のnpmモジュールである、以下のモジュールを使うため、いったんローカルでアーカイブしたものをAWSにアップロードします。

  • request

適当なフォルダに以下の3つのファイル(index.js、alexa-smarthome-utils.js、package.json)を生成します。
ちなみに、alexa-smarthome-utils.jsは、Alexaスキルの実装パターンをActions on Googleの実装パターンに似させるためのものです。

package.json
{
  "name": "smarthome",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "request": "^2.88.0"
  }
}
index.js
const AlexaSmartHomeUtils = require('./alexa-smarthome-utils');
const app = new AlexaSmartHomeUtils();

const request = require('request');

const BASE_URL = process.env.RMMINI3_BASE_URL || '【RESTfulサーバを立ち上げたURL】';
const MANUFACTURE_NAME = process.env.MANUFACTURE_NAME || 'スマートデバイス株式会社';

var accessToken = null;

app.intent('Alexa.Discovery.Discover', async (handlerInput, context) => {
    console.log('Alexa.Discovery.Discover called.');

    var options = {
        url: BASE_URL + '/rm-get-list',
        method: 'POST',
        headers: { 'Content-Type':'application/json' },
        json: {}
    };
    return new Promise((resolve, reject) =>{
        request(options, function (error, response, body) {
            if( error ){
                console.log("error:", error);
                return reject(error);
            }

            var payload = {
                endpoints: []
            };
            var list = body.list;
            for( var i = 0 ; i < list.length ; i++ ){
                var device = {
                    "endpointId": list[i].endpointId,
                    "manufacturerName": MANUFACTURE_NAME,
                    "friendlyName": list[i].friendlyName,
                    "description": list[i].description,
                    "displayCategories": ["SWITCH"],
                    "capabilities": [
                        {
                            "type": "AlexaInterface",
                            "interface": "Alexa",
                            "version": "3"
                        },
                        {
                            "type": "AlexaInterface",
                            "interface": "Alexa.PowerController",
                            "version": "3",
                            "properties": {
                                "supported": [
                                    {
                                        "name": "powerState"
                                    }
                                ],
                                "proactivelyReported": false,
                                "retrievable": false
                            }
                        }
                    ]
                };
                payload.endpoints.push(device);
            };
            var header = JSON.parse(JSON.stringify(handlerInput.directive.header));
            header.name = "Discover.Response";
            context.succeed({ event: { header: header, payload: payload } });

            return resolve();
        });
    });
});

app.intent('Alexa.PowerController.TurnOn', async (handlerInput, context) => {
    console.log('Alexa.PowerController.TurnOn called.');
    await handlePowerControl(handlerInput, context);
});
app.intent('Alexa.PowerController.TurnOff', async (handlerInput, context) => {
    console.log('Alexa.PowerController.TurnOff called.');
    await handlePowerControl(handlerInput, context);
});

async function handlePowerControl(handlerInput, context) {
    var responseHeader = JSON.parse(JSON.stringify(handlerInput.directive.header));
    responseHeader.namespace = "Alexa";
    responseHeader.name = "Response";
    var requestToken = handlerInput.directive.endpoint.scope.token;
    var requestMethod = handlerInput.directive.header.name;
    var endpointId = handlerInput.directive.endpoint.endpointId;

    var options = {
        url: BASE_URL + '/rm-fire',
        method: 'POST',
        headers: { 'Content-Type':'application/json', 'Authorization': 'Bearer ' + requestToken },
        json: {
            endpointId: endpointId,
            type: (requestMethod == "TurnOn") ? 'irdata_on' : 'irdata_off'
        }
    };

    return new Promise((resolve, reject) =>{
        request(options, function (error, response, body) {
            if( error ){
                console.log("error:", error);
                return reject(error);
            }

            var contextResult = {
                "properties": [{
                    "namespace": "Alexa.PowerController",
                    "name": "powerState",
                    "value": (requestMethod === "TurnOn") ? 'ON' : 'OFF',
                }]
            };
            var response = {
                context: contextResult,
                event: {
                    header: responseHeader,
                    endpoint: {
                        scope: {
                            type: "BearerToken",
                            token: requestToken
                        },
                        endpointId: endpointId
                    },
                    payload: {}
                }
            };
            context.succeed(response);

            return resolve();
        });
    });
}

exports.handler = app.handle();
alexa-smarthome-utils.js
class AlexaSmartHomeUtils{
    constructor(){
        this.intentHandles = new Map();
    }

    intent( matcher, handle ){
        this.intentHandles.set(matcher, handle);
    }

    handle(){
        return (handlerInput, context) => {
            var intent = handlerInput.directive.header.namespace + '.' + handlerInput.directive.header.name;
            console.log('intent: ' + intent);
            var handler = this.intentHandles.get(intent);
            if( handler )
                return handler(handlerInput, context);
            else
                return Promise.resolve();
        }
    }
}

module.exports = AlexaSmartHomeUtils;

次に、package.jsonに指定した2つのモジュールをローカルにダウンロードするために以下のコマンドを実行します。

npm install

あとは出来上がった以下のファイルとフォルダをZIPにアーカイブします。

  • index.js
  • alexa-smarthome-utils.js
  • node_modules/
  • package.json

出来上がったZIPファイルを、さきほど作成したオレゴンリージョンのLambdaにアップロードします。

とりあえず、この状態でLambdaが動くか確認してみます。
Lambdaの右上のボタンにあるテストボタンを押下し、テストイベントを作成します。例として、「Discovery」という名前を付けました。

{
  "directive": {
    "header": {
      "namespace": "Alexa.Discovery",
      "name": "Discover",
      "payloadVersion": "3",
      "messageId": "1bd5d003-31b9-476f-ad03-71d471922820"
    },
    "payload": {
      "scope": {
        "type": "BearerToken",
        "token": "access-token-from-skill"
      }
    }
  }
}

テストイベントが作り終わったら、右上の「テスト」ボタンを押下して実行してみます。。。。とその前に、ちょっとソースコードの変更が必要です。
index.jsの以下の部分です。

const BASE_URL = process.env.RMMINI3_BASE_URL || '【RESTfulサーバのURL】';

このURLは、前回の投稿で立ち上げた黒豆のRESTfulサーバのURLになりますので、立ち上げたURLに書き換えるか、環境変数RMMINI3_BASE_URLに指定してください。
当然ながら、アクセス元であるLambdaはインターネットを返してアクセスしてきますので、ローカルIPアドレスではなく、インターネットから接続を受けつけられるURLである必要があります。

RESTfulサーバは起動できていますか?そうであれば、テストボタンを押下してみてください。うまくいけば、なんか応答が返ってきているのがわかります。

image.png

スマートホームスキルの公開設定

それでは、Alexa Skills Kit開発者コンソールに戻って、スマートホームスキルの公開設定をします。
上の方に、公開タブがあります。

*がついている項目はすべて記入する必要があります。

公開名、説明、詳細な説明、サンプルフレーズ、小さなスキルアイコン、大きなスキルアイコン、カテゴリー、プライバシーとコンプライアンス、輸出コンプライアンス、テストの手順(適当で。。)、公開範囲。
プライバシーポリシーのURLも必要です。とりあえずまずは適当でいいですが。
ベータテストはあとで。
スキルアイコンも指定が必要なので、512x512と108x108のアイコンを用意しておきましょう。
以上、記入したら、保存しておきましょう。

image.png

image.png

image.png

image.png

最後には、自動的にチェックが走って、すべて入力されていれば、「エラーは見つかりませんでした」と表示されるはず!

それでは、最後に、Alexaアプリからつながるように公開しましょう。公開するといっても、一般公開ではなく、自分だけに公開するベータテストとして公開します。

さきほど公開のための記入の途中で出てきたかと思いますが、このベータテストの欄に自身のメールアドレスを入力します。

image.png

保存すると、そのメールアドレス宛にメールが飛んでいるかと思います。

動作確認

動作確認するためのアプリは、AndroidまたはiPhoneアプリの「Amazon Alexaアプリ」です。

送られたメールを開くと、Alexaの開始 ボタンがあるWebページが開きます。
そのボタンを押すと、Amazon Alexaアプリが立ち上がります。
もしまだインストールしていなければ、「またはAlexaアプリをダウンロード」を選択してインストールしておきます。

image.png

image.png

そうすると、スキルをテストする? と聞いてきます。当然テストするので、「スキルテスト」ボタンを押下します。

image.png

これにより、公開前ですが、ベータテストとしてスキルを選択できるようになりました。早速、スキルを有効にしてみます。

image.png

有効にするボタンを押下します。
そうすると、Webブラウザが開いて、AWS Cognitoで作成したユーザプールのログイン画面が表示されます。
アカウントリンク設定でAWS CognitoのURLを指定していましたね。
スマートホームスキルでは、スキルの接続時には必ずアカウントリンクが行われます。(一度リンク済みにしておけば再度促されることはありません)

image.png

作っておいたユーザアカウントでログインします。
もし最初のログインの場合は、パスワード変更も促されます。
パスワードは、仮パスワードがメールで届いていたはずです。
入力すると、正式なパスワードに変更するように言われますので、指示に従います。
これでアカウントリンクが完了しました。

image.png

続けざまに、端末の検出が促されます。後でもできますが、今やってしまいましょう。

image.png

端末の検出ボタンを押下すると、デバイスを検索しに来ます。
実は、裏で、構築したRESTfulサーバでいうところの/rm-get-listが何回か呼ばれてます。

image.png

成功すると、うれしいことに、1台のスイッチを検出、なんてことを言ってくれます。感動です。

image.png

確かに、1つスイッチが増えています。

image.png

スイッチを選択すると、「寝室の照明」であることがわかります。これは、friendlyNameで入力した文字列です。
(前回の投稿で、寝室の照明 という名前でデバイスを1つ追加していることを想定しています)

image.png

それを押下すると、「オン」「オフ」のボタンが表示されます。
期待に胸を膨らまして、「オン」ボタンを押下すると、irdata_onに指定した赤外線が発信されたことがわかります!「オフ」も同様に、irdata_offに指定した赤外線が発信されたことがわかります!

image.png

もう一つデバイスを追加

さて、最後にもう一つ、デバイスを追加します。
RESTfulサーバでいうところの、/get-cputempです。

先に、Alexaスマートホームスキルの設定が必要です。
Alexa Skills Kit開発者コンソールのアクセス権限を選択してください。

image.png

Lambdaのソースコードを以下に書き換えてください。

index.js
const AlexaSmartHomeUtils = require('./alexa-smarthome-utils');
const app = new AlexaSmartHomeUtils();

const request = require('request');

const BASE_URL = process.env.RMMINI3_BASE_URL || '【RESTfulサーバのURL】';
const MANUFACTURE_NAME = process.env.MANUFACTURE_NAME || 'スマートデバイス株式会社';
const ALEXA_CLIENT_ID = AlexaスキルメッセージングのクライアントID;
const ALEXA_CLIENT_SECRET = Alexaスキルメッセージングのクライアントシークレット;

var accessToken = null;

app.intent('Alexa.Discovery.Discover', async (handlerInput, context) => {
    console.log('Alexa.Discovery.Discover called.');

    var options = {
        url: BASE_URL + '/rm-get-list',
        method: 'POST',
        headers: { 'Content-Type':'application/json' },
        json: {}
    };
    return new Promise((resolve, reject) =>{
        request(options, function (error, response, body) {
            if( error ){
                console.log("error:", error);
                return reject(error);
            }

            var payload = {
                endpoints: []
            };
            var list = body.list;
            for( var i = 0 ; i < list.length ; i++ ){
                var device = {
                    "endpointId": list[i].endpointId,
                    "manufacturerName": MANUFACTURE_NAME,
                    "friendlyName": list[i].friendlyName,
                    "description": list[i].description,
                    "displayCategories": ["SWITCH"],
                    "capabilities": [
                        {
                            "type": "AlexaInterface",
                            "interface": "Alexa",
                            "version": "3"
                        },
                        {
                            "type": "AlexaInterface",
                            "interface": "Alexa.PowerController",
                            "version": "3",
                            "properties": {
                                "supported": [
                                    {
                                        "name": "powerState"
                                    }
                                ],
                                "proactivelyReported": false,
                                "retrievable": false
                            }
                        }
                    ]
                };
                payload.endpoints.push(device);
            };
/* 追加部分(ここから) */
            var device = {
                "endpointId": "test_endpointId",
                "manufacturerName": MANUFACTURE_NAME,
                "friendlyName": '寝室の温度計',
                "description": '寝室にある温度計です。',
                "displayCategories": ["TEMPERATURE_SENSOR"],
                "capabilities": [
                    {
                        "type": "AlexaInterface",
                        "interface": "Alexa",
                        "version": "3"
                    },
                    {
                        "type": "AlexaInterface",
                        "interface": "Alexa.TemperatureSensor",
                        "version": "3",
                        "properties": {
                            "supported": [
                                {
                                    "name": "temperature"
                                }
                            ],
                            "proactivelyReported": false,
                            "retrievable": true
                        }
                    }
                ]
            };
            payload.endpoints.push(device);            
/* 追加部分(ここまで) */
            var header = JSON.parse(JSON.stringify(handlerInput.directive.header));
            header.name = "Discover.Response";
            context.succeed({ event: { header: header, payload: payload } });

            return resolve();
        });
    });
});

app.intent('Alexa.PowerController.TurnOn', async (handlerInput, context) => {
    console.log('Alexa.PowerController.TurnOn called.');
    await handlePowerControl(handlerInput, context);
});
app.intent('Alexa.PowerController.TurnOff', async (handlerInput, context) => {
    console.log('Alexa.PowerController.TurnOff called.');
    await handlePowerControl(handlerInput, context);
});

async function handlePowerControl(handlerInput, context) {
    var responseHeader = JSON.parse(JSON.stringify(handlerInput.directive.header));
    responseHeader.namespace = "Alexa";
    responseHeader.name = "Response";
    var requestToken = handlerInput.directive.endpoint.scope.token;
    var requestMethod = handlerInput.directive.header.name;
    var endpointId = handlerInput.directive.endpoint.endpointId;

    var options = {
        url: BASE_URL + '/rm-fire',
        method: 'POST',
        headers: { 'Content-Type':'application/json', 'Authorization': 'Bearer ' + requestToken },
        json: {
            endpointId: endpointId,
            type: (requestMethod == "TurnOn") ? 'irdata_on' : 'irdata_off'
        }
    };

    return new Promise((resolve, reject) =>{
        request(options, function (error, response, body) {
            if( error ){
                console.log("error:", error);
                return reject(error);
            }

            var contextResult = {
                "properties": [{
                    "namespace": "Alexa.PowerController",
                    "name": "powerState",
                    "value": (requestMethod === "TurnOn") ? 'ON' : 'OFF',
                }]
            };
            var response = {
                context: contextResult,
                event: {
                    header: responseHeader,
                    endpoint: {
                        scope: {
                            type: "BearerToken",
                            token: requestToken
                        },
                        endpointId: endpointId
                    },
                    payload: {}
                }
            };
            context.succeed(response);

            return resolve();
        });
    });
}

/* 追加部分(ここから) */
app.intent('Alexa.Authorization.AcceptGrant', async (handlerInput, context) => {
    console.log('Alexa.Authorization.AcceptGrant called.');
    var code = handlerInput.directive.payload.grant.code;

    var options = {
        url: 'https://api.amazon.com/auth/o2/token',
        method: 'POST',
        headers: { 'Content-Type':'application/json' },
        json: {
            grant_type: 'authorization_code',
            code: code,
            client_id: ALEXA_CLIENT_ID,
            client_secret: ALEXA_CLIENT_SECRET
        }
    };

    return new Promise((resolve, reject) =>{
        request(options, function (error, response, body) {
            if( error ){
                console.log("error:", error);
                return reject(error);
            }

            console.log(body);

            accessToken = body.access_token;

            var header = JSON.parse(JSON.stringify(handlerInput.directive.header));
            header.name = "AcceptGrant.Response";
            context.succeed({ event: { header: header, payload: {} }});

            return resolve();
        });
    });
});

app.intent('Alexa.ReportState', async (handlerInput, context) => {
    console.log('Alexa.ReportState called.');
    var responseHeader =  JSON.parse(JSON.stringify(handlerInput.directive.header));
    responseHeader.namespace = "Alexa";
    responseHeader.name = "StateReport";
    var endpointId = handlerInput.directive.endpoint.endpointId;
    var requestToken = handlerInput.directive.endpoint.scope.token;

    var options = {
        url: BASE_URL + '/get-cputemp',
        method: 'POST',
        headers: { 'Content-Type':'application/json', 'Authorization': 'Bearer ' + requestToken },
        json: {}
    };

    return new Promise((resolve, reject) =>{
        request(options, function (error, response, body) {
            if( error ){
                console.log("error:", error);
                return reject(error);
            }

            var contextResult = {
                "properties": [
                    {
                        "namespace": "Alexa.TemperatureSensor",
                        "name": "temperature",
                        "value": {
                            "value" : body.temp,
                            "scale" : 'CELSIUS'
                        }
                    }
                ]
            };

            var response = {
                context: contextResult,
                event: {
                    header: responseHeader,
                    endpoint: {
                        scope: {
                            type: "BearerToken",
                            token: requestToken
                        },
                        endpointId: endpointId
                    },
                    payload: {}
                }
            };
            context.succeed(response);
            
            return resolve();
        });
    });
});
/* 追加部分(ここまで) */

exports.handler = app.handle();

追加した部分は、コメントで /* 追加部分(ここから) */ で示しておきました。
ソースコードの一部書き換えが必要です。
【AlexaスキルメッセージングのクライアントID】
【Alexaスキルメッセージングのクライアントシークレット】
の部分を、先ほど覚えて置いた値に書き換えます。

で、もう一度、デバイスを追加します。デバイスを追加 → その他 → デバイスを検出ボタンを押下します。
そうすると、今度はサーモスタットが追加されました!

image.png

こんな感じで、ラズパイのCPU温度が表示されています!

image.png

このサーモスタットは、赤外線On・Offと違って、定期的にLambda側から状態問い合わせが来ています。それに答えるLambdaにロジックを追加していたのです。

2回の投稿を経て、やっと目的を達成しました。
非常に長い道のりでした。でも、実際に動くのを体験するとうれしいです。

以上です

13
12
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
13
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?