3
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.

IoT初心者がSORACOM Buttonのボタンが押されたらkintoneに記録するハンズオンの準備

Last updated at Posted at 2022-06-30

コミュニティ活用フリー

このコンテンツは商用以外でしたらSORACOM UGなどのコミュニティで活用する場合は、内容をそのままコピーしたり、アレンジしてご利用いただけます。但し、記載内容について著者は何ら保証するものではありません。あくまで自己責任でご利用ください。

はじめに

SORACOM Button はボタンを押すと通知できる、IoT初心者にもわかりやすいシンプルなデバイスです。

IoT初心者向けハンズオンを想定した場合、ボタン結果表示はSORACOM Lagoonでも可視化できますが、今回はノーコードで簡単にアプリが作成できるkintoneで可視化するハンズオンについて検討してみました。
BTN205.png

ハンズオンのコンテンツ自体はこちらを参照ください。

事前準備

ハンズオンの準備に必要な環境を以下に掲載します。
TYPE02.png

kintoneの環境は試用版などを準備して、事前にハンズオン用のアカウントなどを作成しておきます。

Amazon AWSの部分は初心者向けのハンズオンでは対象にしない方が良いので、主催側で準備して提供すると良いでしょう。

SORACOM LTE-M Button は最初からSORACOMユーザコンソールに紐づけられています。
SORACOM-UGなどのコミュニティでハンズオンを行う際には、事前にSORACOMさんに相談してコンソール環境と一緒にSORACOM LTE-M Buttonお借りする必要があります。

kintoneの設定

先ず最初にkintoneのアプリを準備します。
この部分は簡単ですので、予めkintoneの試用版を準備しておいてハンズオンで作成してもらっても良いかもしれません。
kintoneのハンズオンの準備については、以下をご参照すると良いでしょう。
kintone ノーコード開発体験ハンズオン(事前準備)

今回は一部を除きkintone初心者でも作成できる以下のようなボタン試験アプリを検討します。
BTN201.png

フィールド名 タイプ フィールドコート 備考
日時 日時 日時 レコード登録日時を初期値
ボタンアクション 文字列(1行) ボタンアクション
ボタンアクション説明 文字列(1行 ボタンアクション説明 自動計算式*1を設定
緯度 数値 緯度
経度 数値 経度
バッテリーレベル 数値 バッテリーレベル
IMSI 文字列(1行) IMSI
IMEI 文字列(1行) IMEI

このkintoneアプリのテンプレートは以下より入手できるようにしています。

*1 自動計算式
自動計算式の「ボタンアクション説明」は必須ではありませんが、ボタンの状態をわかりやすくできます。
以下を1行にしてコピペできるように準備するとkintone初心者でも対応できそうです。

IF(ボタンアクション="SINGLE", "ボタンを1回押した", 
  IF(ボタンアクション="DOUBLE", "ボタンを2回押した", 
    IF(ボタンアクション="LONG", "ボタンを長押しした", "")
  )
)

BTN202.png

kitoneに外部APIからデータをPOSTしてレコードを追加するための認証には、ユーザアカウントと、トークンが利用可能です。ユーザアカウントはBase64エンコードする手間や、パスワード漏洩につながる可能性みまりますので、トークンが使えるように設定しておくと良いでしょう。
BTN203.png
その他、一覧やグラフもあると便利ですが、この辺りは任意で。
BTN204.png

AWS側の設定

AWSマネジメントコンソールでAWS LambdaとAWS APIGatewayの設定を行います。
BTN100.png

こちらを初心者ハンズオンに含めるには難易度が高すぎますので、主催側で利用できる環境を準備します。

AWS Lambdaの設定

先ずは、SORACOMボタンから受信したデータをkintoneのレコードとして保管する処理を行う、AWS Lambdaの関数を追加します。

「一から作成」を選択、関数名を入力、ランタイムを選択して関数の作成を行います。こちらではランタイムにNode.js 14を選択した例をご紹介します。
BTN001.png

続いて「設定」タブを選択、「一般設定」を選択して編集します。
BTN002.png

以下の画面が表示されますので、説明を入力、メモリを256MB前後に変更、タイムアウトを10~15秒に設定します。
BTN003.png

事前にnode.jsのライブラリィと、以下のサンプルコード(index.js)をzipファイルで固めたファイルを用意します。

index.jsサンプルコード
index.js
'use strict';

require('date-utils');
const fetch = require('node-fetch');

// kintone
const Host     = "cybozu.com";
const Protocol = "https://";
const Path     = "/k/v1/record.json";

exports.handler = async (event) => {
    
    // 簡単なセキュリティ対策例(環境変数のSeacretと合致するかチェック)
    if(event.headers.seacret !== process.env['Seacret']){
        let response = {
            statusCode: 400,
            body: JSON.stringify('Bad Request.'),
        };
        return response;
    }
    
    // kintoneに送るデータを編集
    let json = JSON.parse(event.body);
    let dt = new Date();    
    let parm = { 
        "日時": { "value" : dt.toFormat("YYYY-MM-DDTHH24:MI:SSZ") },
        "ボタンアクション": { "value" : json.clickTypeName },
        "バッテリーレベル": { "value" : json.batteryLevel },
        "IMSI": { "value" : event.headers['x-soracom-imsi'] },
        "IMEI": { "value" : event.headers['x-soracom-imei'] }
    };
    if(event.headers['x-soracom-geo-position-query-result'] === 'success'){
        let latlang = event.headers['x-soracom-geo-position'].split(';');
        parm.緯度 = { "value" : latlang[0] };
        parm.経度 = { "value" : latlang[1] };
    }
    
    // kintoneにデータを追加
    let url = Protocol + event.headers['kintone-host'] + '.' + Host + Path;
    await PostKintoneRecode(url, event.headers['kintone-appid'], event.headers['kintone-token'], parm);
    
    // レスポンスを返す
    let response = {
        statusCode: 200,
        body: JSON.stringify('Post Data.'),
    };
};

// kintoneにデータを追加
async function PostKintoneRecode(url, appId, token, parm)
{
    try {
        const headers = {
            'Content-type': 'application/json',
            'X-Cybozu-API-Token': token
        };
        const body = JSON.stringify({ 'app' : appId, 'record' : parm });
        const response = await fetch(url, {
            method: 'post',
            body: body,
            headers: headers
        });
        const data = await response.json();
        console.log(data);
        return true;
    } catch (error) {
        console.log(JSON.stringify(error));
        return false;
    }
}

この例ではnode.jsのライブラリィnode-fetchとdate-utilsを追加する必要があります。
https://www.npmjs.com/package/node-fetch
https://www.npmjs.com/package/date-utils

Node.jsのパッケージを作成しLambdaデプロィする方法については以下が参考になります。
Node.js の Lambda デプロイパッケージを作成するには、どうすればよいですか?
.zip ファイルアーカイブで Node.js Lambda 関数をデプロイする
Lambda の Node.js でもっといろんなパッケージを使いたいとき

作成したZipファイルをデプロィすると以下のようになります。
BTN004.png

環境変数の設定

簡単なセキュリティ対策の例として、プログラムの環境変数 process.env['Seacret'] を以下の例のように追加します。
aws09.png
ハンズオン用ですので、難しい文字列にならない方が良いでしょう。
aws11.png

Function URLsの設定

以前はAmazon APIGatewayを設定していましたが、簡易に外部APIとして利用できるAWS Lambda Function URLs が使えるようになりましたので、そちらを設定して利用します。
aws13.png
ハンズオン用として、以下の例では誰でもアクセスできるように設定しています。
aws15.png
aws16b.png
設定が完了すると「関数のARN」の下に「関数URL」が表示されてAPIとして利用できるようになります。
aws16.png

SORACOMコンソールの設定

最後にSORACOMのコンソール画面でボタンの設定を行います。

SORACOMコンソールの左メニューを開き、ガジェット管理の「LTE-M Button for Enterprize/Plus」を開きます。
BTN301.png

該当するボタンを選択し、デバイスの設定変更を開きます。
BTN302.png

新規グループ名を追加し「次へ:設定を編集」に進みます。
BTN303.png

簡易位置即位機能にチェックを入れ、下にスクロールして「保存」します。
BTN304.png

設定を保存したら、デバイス一覧に戻ります。
BTN305.png

デバイス一覧に戻ると、再度該当するボタンを選択し「SIMグループを編集」に進みます。
BTN306.png

以下の画面が表示されますので、SORACOM Air for セルラー設定と、SORACOM Beam設定を行います。
BTN307.png

SORACOM Air for セルラー設定が以下になっているか確認します。
BTN308.png

SORACOM Beam設定を開き、左端の「+」をクリックして新しい設定を追加します。
BTN309.png

「UDP→HTTP/HTTPSエントリポイント」を選択します。
BTN310.png

先にAmazon API Gatewayの設定で控えたURLを以下のように記載します。
BTN311.png

IMSIヘッダとIMEIヘッダをONにして取得できるようにします。
BTN312.png
IMSI とは、携帯電話の加入者に発行される、国際的な加入者識別番号です。携帯電話事業者と契約の際に発行され、SIMカード(UIMカード/USIMカード)に記録さています。正確にはSIMカードごとに固有の番号であり、一人で複数の契約を結べば契約(今回の場合はボタン)ごとに発行されます。

IMEI とは、携帯電話(今回の場合はボタン)の製造番号です。

カスタムヘッダの左端「+」をクリックして、以下のヘッダを追加します。
BTN314.png

アクション ヘッダ名 備考
追加 Kintone-Host kintoneのサブドメイン名 hogehoge.cybozu.comのhogehogeが該当
追加 Kintone-Appid kintoneのアプリID 先に追加したkintoneアプリのID
追加 Kintone-Token kintoneのアプリのtoken 先に追加したkintoneアプリで取得したトークン

「保存」すると以下の画面に戻り、設定が追加されたことが確認できます。
BTN315.png

以上で全ての準備が整いましたので、ボタンを押してランプが以下の緑になるとkintoneのレコードが追加されます。
IMG_4649.JPG
BTN205.png
ボタンのランプが赤色や、緑になってもkintoneにレコードが追加されない場合は次章の「試験結果の確認」を参照します。

試験結果の確認

ボタンのランプが赤色になる場合や、緑になってもkintoneにレコードが追加されない場合はAWS Lambdaの関数画面の「モニタリング」の「ログ」画面で「CloudWatchのログを表示」します。
aws51.png

以下の最新の「ログストリーム」を確認します。
BTN113.png

エラーがあれば以下に詳細なエラーログが出力されていますので、そちらを確認します。
BTN114.png
エラーがない場合は、AWS Lambdaのnode.jsプログラムでデバッグ出力を追加して確認します。

積色灯(パトランプ)を利用する

画像n.jpg
ボタンの押した結果をkintoneのアプリに保管するだけではハンズオンがちょっと地味ですので、もっと楽しんでもらうためにボタンを押すと押し方に合わせて積色灯を点灯するしくみを追加してみました。

利用した積色灯について

今回用意したのは以下の「パトライト USB制御積層信号灯 LR6-3USBW-RYG」という製品です。

Amazonで3万円弱とちょっと高価ですが、PCとUSB接続するだけで利用できるので、面倒な配線などの作業を省略できて使い勝手は良いです。

機器のソフト開発に関する説明書や、Windows OS用の開発ライブラリィ、サンプルコードは以下で会員登録すると無償でダウンロードできます。

積色灯を含めた場合の構成と変更点

構成は以下になります。
TYPE04.png
先に用意したAWS Lambdaのプログラムに、AWS IoT CoreのMQTTSのエンドポイントにボタンの情報を送信(Publish)し、会場のPCでMQTTSの受信(Subscribe)する仕組みで積色灯を点灯します。

先に用意したAWS Lambdaのプログラムの環境変数に、以下のMQTTSのエンドポイントを設定します。
aws52.png

MQTTSのエンドポイントはAWS IoTの設定画面で確認することができます。
61.png

続いて以下の「2 モノの作成」参考にAWS IoTでモノの作成を行い、証明書を発行してルート証明と一緒にダウンロードしておきます。

ルート証明は以下のURLから直接ダウンロード可能です。(2022/06/28現在)
https://www.websecurity.digicert.com/content/dam/websitesecurity/digitalassets/desktop/pdfs/roots/VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem

先のサンプルコード(index.js)にコメント「パトランプ用MQTTS送信(Publish)」部分を追加します。

index.jsサンプルコード
index.js
'use strict';

require('date-utils');
const fetch = require('node-fetch');

// kintone
const Host     = "cybozu.com";
const Protocol = "https://";
const Path     = "/k/v1/record.json";

// AWS IoT
const endpoint = process.env['Endpoint'];
const topic    = "soracom/button"
const aws      = require('aws-sdk');
const region   = 'ap-northeast-1';

exports.handler = async (event) => {
    
    // 簡単なセキュリティ対策例(環境変数のSeacretと合致するかチェック)
    if(event.headers.seacret !== process.env['Seacret']){
        let response = {
            statusCode: 400,
            body: JSON.stringify('Bad Request.'),
        };
        return response;
    }
    
    // kintoneに送るデータを編集
    let json = JSON.parse(event.body);
    let dt = new Date();    
    let parm = { 
        "日時": { "value" : dt.toFormat("YYYY-MM-DDTHH24:MI:SSZ") },
        "ボタンアクション": { "value" : json.clickTypeName },
        "バッテリーレベル": { "value" : json.batteryLevel },
        "IMSI": { "value" : event.headers['x-soracom-imsi'] },
        "IMEI": { "value" : event.headers['x-soracom-imei'] }
    };
    if(event.headers['x-soracom-geo-position-query-result'] === 'success'){
        let latlang = event.headers['x-soracom-geo-position'].split(';');
        parm.緯度 = { "value" : latlang[0] };
        parm.経度 = { "value" : latlang[1] };
    }
    
    // kintoneにデータを追加
    let url = Protocol + event.headers['kintone-host'] + '.' + Host + Path;
    await PostKintoneRecode(url, event.headers['kintone-appid'], event.headers['kintone-token'], parm);
    
    // パトランプ用MQTTS送信(publish)
    let iotdata = new aws.IotData( {endpoint: endpoint, region: region});
    var params = {
        topic: topic,
        payload: json.clickTypeName + " " + event.headers['x-soracom-imei'],
        qos: 0
    };
    await iotdata.publish(params).promise();
    
    // レスポンスを返す
    let response = {
        statusCode: 200,
        body: JSON.stringify('Post Data.'),
    };
};

// kintoneにデータを追加
async function PostKintoneRecode(url, appId, token, parm)
{
    try {
        const headers = {
            'Content-type': 'application/json',
            'X-Cybozu-API-Token': token
        };
        const body = JSON.stringify({ 'app' : appId, 'record' : parm });
        const response = await fetch(url, {
            method: 'post',
            body: body,
            headers: headers
        });
        const data = await response.json();
        console.log(data);
        return true;
    } catch (error) {
        console.log(JSON.stringify(error));
        return false;
    }
}

こちらにそのままAWS Lambdaにデプロィ可能なZipファイルを置いています。
先に説明したnode.jsのライブラリィ等のライセンスを確認のうえ、ご自身の責任でご利用いただける場合はご活用ください。

PC側の準備

先にご紹介したサイトから LR6-USB_Sample_code.zip というサンプルコードを入手可能です。
Microsoft Visual Studioでのプログラム開発の経験がある方でしたら、こちらをVisual Studioで開き、サンプルコードを参照するだけで簡単にプログラムを実装できます。

今回説明書のAPIを参照して、Raspberry PiのPythonでUSB HIDデバイス(積色灯のUSB)に接続する方法を試してみましたが、USBの給電?か何かの問題でうまくいきませんでした。仕方なくWindows10 ProのVisual Studio 2017のC#で積色灯の点灯とブザーが鳴る昨日を実装しました。
なお、Visual Studioは商用に使わなければCommunity版も利用できますので、そちらでも実装可能です。

先ずは、Visual Studioで新規に作成したプロジェクトに、サンプルコード \LR6-USB_Sample_code\CS\Samplecode\Form1.cs を参考に、USB_PAT_Tower.dll の各メソッド等をDllImportします。

続いて、以下を参考にC#でMQTTSの受信(Subscribe)ができるようにします。

Visual Studio for Mac(C#)でAWS IoTへ接続してみた
https://dev.classmethod.jp/articles/visual-studio-for-mac-csharp-aws-iot/

プログラムから使用するために、証明書と秘密鍵をPKCS12ファイルにする必要があり、以下を参考にopensslをインストールします。

Windows にopenssl をインストールする方法
https://oji-cloud.net/2021/03/26/post-6040/

インストールが完了したら、先にAWS IoTでモノの作成を行い発行した証明書と、ダウンロードしたルート証明を保管したフォルダーで以下のコマンドを実行します。

openssl pkcs12 -export -in SoracomHanzon-certificate.pem.crt -inkey SoracomHanzon-private.pem.key -out SoracomHanzon.pfx -certfile AmazonRootCA1.pem
Enter Export Password:<パスワード>
Verifying - Enter Export Password:<パスワード>

上記のの例では、
SoracomHanzon-certificate.pem.crt
SoracomHanzon-private.pem.key
がAWS IoTでモノの作成を行い発行した証明書で、
AmazonRootCA1.pem
が、ダウンロードしたルート証明です。

以下のサンプルコードでは、出力したSoracomHanzon.pfxファイルとルート証明のAmazonRootCA1.pemファイルを利用します。

C#サンプルコード
Patlamp.cs
using System.Runtime.InteropServices;

namespace ConsoleApp1
{
    public class Patlamp
    {
       // <重要>ここで USB_PAT_Tower.dll のメソッド等を DllImport します 
       // 著作が曖昧なため、こちらへの引用はしていません
       // 
       // USB_PAT_Tower.dll の各メソッド等を DllImport するコードは 
       // 株式会社パトライト さんのサンプル \LR6-USB_Sample_code\CS\Samplecode\Form1.cs 
       // を参考にをそのまま引用したため掲載を避けました
       // サンプルは https://www.patlite.co.jp/login/ にて会員登録後に無償でダウンロードできます
       
       private int UTPOpen = -1;

        public Patlamp()
        {
            UTPOpen = UPT_Open();
            if(UTPOpen == 0)
            {
                UPT_Reset();
            }
        }

        public void Red()
        {
            if (UTPOpen == 0)
            {
                UPT_SetTower(ON_STATIC, OFF_STATIC, OFF_STATIC, OFF_STATIC, ON_STATIC);
            }
        }

        public void Yellow()
        {
            if (UTPOpen == 0)
            {
                UPT_SetTower(OFF_STATIC, ON_STATIC, OFF_STATIC, ON_STATIC, OFF_STATIC);
            }
        }

        public void Green()
        {
            if (UTPOpen == 0)
            {
                UPT_SetTower(OFF_STATIC, OFF_STATIC, ON_STATIC, OFF_STATIC, OFF_STATIC);
            }
        }

        public void all()
        {
            if (UTPOpen == 0)
            {
                UPT_SetTower(ON_STATIC, ON_STATIC, ON_STATIC, OFF_STATIC, OFF_STATIC);
            }
        }

        public void Beep()
        {
            if (UTPOpen == 0)
            {
                UPT_SetBuzEx(ON_STATIC, 0, BUZ_PITCH1, BUZ_PITCH_DFLT);
            }
        }

        public void Clear()
        {
            if (UTPOpen == 0)
            {
                UPT_SetTower(OFF_STATIC, OFF_STATIC, OFF_STATIC, OFF_STATIC, OFF_STATIC);
            }
        }

        public void Reset()
        {
            if (UTPOpen == 0)
            {
                UPT_Reset();
            }
        }

        public void Close()
        {
            if (UTPOpen == 0)
            {
                UPT_Close();
                UTPOpen = -1;
            }
        }
    }
}

Program.cs
using System;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using uPLibrary.Networking.M2Mqtt;
using uPLibrary.Networking.M2Mqtt.Messages;

namespace ConsoleApp1
{
    class Program
    {

        const string endpoint = "AWS IoTCore MQTTS Host Address";
        const string caname = "key/AmazonRootCA1.pem";
        const string pfxname = "key/SoracomHanzon.pfx";
        const string pfxpassword = "SSLパスワード";
        const string topic = "soracom/button";

        static void Main(string[] args)
        {
            var patlamp = new Patlamp();
            patlamp.Clear();
            patlamp.Close();

            var caCert = X509Certificate.CreateFromCertFile(caname);
            var clientCert = new X509Certificate2(pfxname, pfxpassword);

            AWSIot iot = new AWSIot(endpoint, clientCert, caCert);

            // Subscribe
            iot.Recv += Recv;
            iot.Subscribe(topic);

            Console.ReadLine();
        }

        // MQTTSでメッセージを受信した
        static void Recv(string message)
        {
            int sleep = 500;
            Console.WriteLine(message);

            var patlamp = new Patlamp();
            // ボタンを1回押したら緑を1回点灯
            if (message.IndexOf("SINGLE") >= 0)
            {
                patlamp.Beep();
                patlamp.Green();
                Thread.Sleep(sleep);
                patlamp.Reset();
            }
            // ボタンを長押ししたら黄色を長めに点灯
            else if(message.IndexOf("LONG") >= 0)
            {
                patlamp.Beep();
                patlamp.Yellow();
                Thread.Sleep(sleep * 3);
                patlamp.Reset();
            }
            // ボタンを2回押したら赤を2回点灯
            else if (message.IndexOf("DOUBLE") >= 0)
            {
                patlamp.Beep();
                patlamp.Red();
                Thread.Sleep(sleep);
                patlamp.Reset();
                Thread.Sleep(sleep);
                patlamp.Beep();
                patlamp.Red();
                Thread.Sleep(sleep);
                patlamp.Reset();
            }
            patlamp.Close();
        }
    }

    delegate void RevcHandler(string message);

    class AWSIot
    {
        public event RevcHandler Recv = null;
        private const int BrokerPort = 8883;
        MqttClient client;

        public AWSIot(string endpoint, X509Certificate2 clientCert, X509Certificate rootCa)
        {
            client = new MqttClient(endpoint, BrokerPort, true, rootCa, clientCert, MqttSslProtocols.TLSv1_2);
            client.MqttMsgPublishReceived += MessageReceived;
            string clientId = Guid.NewGuid().ToString();
            client?.Connect(clientId);
            if (client.IsConnected)
            {
                Console.WriteLine("AWSIot Connected.");
            }
        }

        public void Subscribe(string Topic)
        {
            client.Subscribe(new[] { Topic }, new[] { MqttMsgBase.QOS_LEVEL_AT_LEAST_ONCE });
        }

        public void Publish(string Topic, string message)
        {
            client.Publish(Topic, Encoding.UTF8.GetBytes(message));
        }

        void MessageReceived(object sender, uPLibrary.Networking.M2Mqtt.Messages.MqttMsgPublishEventArgs e)
        {
            Recv(new String(Encoding.UTF8.GetChars(e.Message)));
        }
    }
}

上記のコードをPCのVisual Studioでデバック実行するか、Visual Studioで出力したEXEファイルを実行すると積色灯がボタンに連動して点灯するようになります。

ハンズオンの手順

以上、以下のハンズオンを実施するための主催側の準備を含めた環境構築の手順をご紹介してきました。

TYPE04.png

SORACOMの初心者をターゲットにハンズオンを実施した場合、ほどんどの参加者に完走してもらうのは余裕を持って1時間以上時間を確保することをお勧めします。
参考までに1時間半の場合、1時間の場合のハンズオンコース設定例をご紹介します。

1時間半コース

もし、ハンズオンの時間が十分(1時間半程度)確保できる場合は以下の手順が良いでしょう。

  • 主催側はAWSに関する設定を事前に全て行い、環境を提供する
  • ハンズオン参加者は先ずkintoneのアプリを作成する
  • ハンズオン参加者は続いてSORACOMのコンソールでボタンとBeamの設定を行う
  • ハンズオン参加者はボタンを試験して、kintoneのアプリで結果が記録されるのを確認する

1時間コース

もし、ハンズオンの時間が少ない(1時間程度)場合は以下の手順が良いでしょう。

  • 主催側はAWSに関する設定を事前に全て行い、環境を提供する
  • 主催側はハンズオン人数分のkintoneのアプリを用意しておく
  • ハンズオン参加者は続いてSORACOMのコンソールでボタンとBeamの設定を行う
  • ハンズオン参加者はボタンを試験して、kintoneのアプリで結果が記録されるのを確認する

以上「IoT初心者がSORACOM Buttonのボタンが押されたらkintoneに記録するハンズオン」を準備するためのご説明でした。

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