LoginSignup
3
4

More than 5 years have passed since last update.

ESP8266 / Slack / Amazon Dash Button > Amazon Dash Buttonを押したらSlackに通知する実装 > v0.1 to v0.4 / ESP8266 WiFi Repeater (NAT Router)

Last updated at Posted at 2016-12-31

http://qiita.com/kat-kai/items/c898a439bafe5e605dae
の記事に触発された記事です。

この記事内のコードはほとんど他の人のコードを切り貼りしています。全ての寄与はそれぞれの人によるものです。

また、コーディングルールを統一はしていません。来年気が向いたらします。

SSL認証に失敗しています。

動作環境

以下を使っています。

実装内容

  1. プロミスキャスモードによりAmazon Dash Buttonの検知を行う
  2. アクセスポイントへWiFi接続する
  3. Slackへ通知する

code v0.1

code > WiFi設定

WifiConfig.h :
自分のアクセスポイントを記載したファイル。
注意: GitHubなどにチェックインしないこと

static const char *kSsid = "pi-31415-g";
static const char *kPass = "47voyager";

code > Slack設定

slackConfig.h :
Slackのチャネル、Incoming WebHooksなどの設定ファイル。

注意: GitHubなどにチェックインしないこと

static const char *kSlackUrl = "/services/PIPIPI/NAPIER/3141592653589793228";
static const String kSlackChannel = "#amazon_dash";
static const String kSlackUsername = "7of9";

kSlackUrl にはIncoming WebHooksのURLの/services/から始まる文字列を設定する。

code > Dash設定

dashConfig.h :
Amazon Dash ButtonのMACアドレス
MACアドレスの調べ方は以下の1つ目のコードを実行します。
http://qiita.com/kat-kai/items/3b1d5c74138d77a27c4d

注意: GitHubなどにチェックインしないこと

uint8_t targetMAC1[6] = { 0x12, 0x34, 0x56, 078, 0x9A, 0xBC};  //Amazon Dash Button MAC address

code > プロミスキャス関連

ESP_Promiscuous.h

以下を使わせていただきました。感謝です。
http://qiita.com/kat-kai/items/3b1d5c74138d77a27c4d

code

esp8266_161231_slackAmazonDash.h
#include <ESP8266WiFi.h>
#include "ESP_Promiscuous.h"
#include "dashConfig.h"
#include "WifiConfig.h"
#include "slackConfig.h"

/*
 * v0.1 Dec. 31, 2016
 *   - add [slack submit] feature
 *   - add [Wifi connection] feature
 *   - add [amazon dash detection] feature
 */

extern "C" {
  #include <user_interface.h>
}

byte channel = 5;  //WiFi channel (1-13)

unsigned long lastMillis = 0;

static const char *kSlackHost = "hooks.slack.com";
static const int kHttpsPort = 443;

boolean willSend = false;

static void ICACHE_FLASH_ATTR promisc_cb(uint8_t *buf, uint16_t len)
{  
    if (len == 12){
      //No accurate information about MAC address and length of the head of packet.
      struct RxControl *sniffer = (struct RxControl*) buf;
      return;
    } else if (len == 128) {
      //Management Packet
      struct sniffer_buf2 *sniffer = (struct sniffer_buf2*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      int i;
      boolean MAC_Matching_Flag = true;

      for (i=0; i<6; i++) if (mac->addr2[i] != targetMAC1[i]) MAC_Matching_Flag = false;

      if (!MAC_Matching_Flag) return; //No hit
      if (millis() - lastMillis < 7000) return; //In order to avoid a duplicate detection; 1 cycle needs around 6,500 msec.

      lastMillis = millis();     

      //Handle it.
      wifi_promiscuous_enable(0);  //イベントを取得したら、WiFi接続のためにプロミスキャスモードを中断。
      willSend = true;
    } else {
      //Data Packet
      struct sniffer_buf *sniffer = (struct sniffer_buf*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      return;
    }
}

void slack_submit()
{
  WiFiClientSecure client;

  // connect
  if (!client.connect(kSlackHost, kHttpsPort)) { 
    Serial.println("slack connection failed");
  } else {
    Serial.println("slack connection: OK");    
  }

  // SSL Certificate finngerprint for the host
  const char* fingerprint = "‎‎‎‎ab f0 5b a9 1a e0 ae 5f ce 32 2e 7c 66 67 49 ec dd 6d 6a 38";
  // verify the signature of the ssl certificate
 if (client.verify(fingerprint, kSlackHost)) {
   Serial.println("ssl cert matches");
 } else {
   Serial.println("ssl cert mismatch");
 }

  // submit
  String message = "Amazon Dash Pushed (ESP8266)";
  String payload="payload={\"channel\": \"" + kSlackChannel + "\", \"username\": \"" + kSlackUsername 
  + "\", \"text\": \"" + message + "\", \"icon_emoji\": \":ghost:\"}";
  Serial.println(payload.c_str());

  client.print("POST ");
  client.print(kSlackUrl);
  client.println(" HTTP/1.1");
  client.print("Host: ");
  client.println(kSlackHost);
  client.println("User-Agent: ArduinoIoT/1.0");
  client.println("Connection: close");
  client.println("Content-Type: application/x-www-form-urlencoded;");
  client.print("Content-Length: ");
  client.println(payload.length());
  client.println();
  client.println(payload);

  // リクエストを受け取る前に5秒以上は待った方がいいらしい
  delay(7000);

  while(client.available()) {
    String line = client.readStringUntil('\r');
    Serial.println(line);
  }
  client.stop();
}

void WiFi_setup()
{
  WiFi.begin(kSsid, kPass);
  while( WiFi.status() != WL_CONNECTED) {
    delay(500); // msec
  }
  Serial.println(WiFi.localIP());
}

void setup() {  
  WiFi.disconnect(); // to avoid WDT reset

  Serial.begin(115200);
  wifi_set_opmode(STATION_MODE);
  wifi_set_channel(channel);
  wifi_set_promiscuous_rx_cb(promisc_cb);

  Serial.println("Ready");

  // Start!
  wifi_promiscuous_enable(1);
}

void loop() {
  delay(100); // msec

  if (!willSend) {
    return;  
  }
  willSend = false;

  Serial.println("ADB push detected.");

  // submit to slack
  WiFi_setup();
  slack_submit();
  Serial.println("submitted to Slack.");

  WiFi.disconnect();

  wifi_promiscuous_enable(1);  //プロミスキャスモード再開
}

実行

実行すると以下となります。

実行
Ready

Amazon Dash Buttonを押しましょう。

結果
Ready
ADB push detected.
192.168.10.2
slack connection: OK
ssl cert mismatch
payload={"channel": "#amazon_dash", "username": "7of9", "text": "Amazon Dash Pushed (ESP8266)", "icon_emoji": ":ghost:"}
submitted to Slack.

qiita.png

すごいですね!(それぞれの仕組みを見つけて実装した人が)

情報感謝です。

備考

ボタンを押す間隔は10秒程度おいた方がよさそうです。
短い間隔で押すとボタン押下検知あとのWiFi接続から進まなくなりました。
プロミスキャスモード移行やその中のdelay()処理などが関係するかもです。

TODO1

SSLの認証はあいかわらず、mismatchです。

TODO2

これをもって、バッテリーテストをしようと考えています。

  1. CR-123A (1400mAh?)をESP8266の電源とする
  2. ESP8266を放置
  3. 時々Amazon Dash Buttonを押す
  4. 3の繰り返し

Slackの投稿を見て、およその動作時間が分かります。
数日かかるかもしれません。

休み明けにテスト開始する予定です。

v0.2 > 起動時Slack通知

起動時のメッセージをSlack通知するように変更しました。

code

esp8266_161231_slackAmazonDash.ino
#include <ESP8266WiFi.h>
#include "ESP_Promiscuous.h"
#include "dashConfig.h"
#include "WifiConfig.h"
#include "slackConfig.h"

/*
 * v0.2 Dec. 31, 2016
 *   - slack_submit() takes [message] arg
 * v0.1 Dec. 31, 2016
 *   - add [slack submit] feature
 *   - add [Wifi connection] feature
 *   - add [amazon dash detection] feature
 */

extern "C" {
  #include <user_interface.h>
}

byte channel = 5;  //WiFi channel (1-13)

unsigned long lastMillis = 0;

static const char *kSlackHost = "hooks.slack.com";
static const int kHttpsPort = 443;

boolean willSend = false;

static void ICACHE_FLASH_ATTR promisc_cb(uint8_t *buf, uint16_t len)
{  
    if (len == 12){
      //No accurate information about MAC address and length of the head of packet.
      struct RxControl *sniffer = (struct RxControl*) buf;
      return;
    } else if (len == 128) {
      //Management Packet
      struct sniffer_buf2 *sniffer = (struct sniffer_buf2*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      int i;
      boolean MAC_Matching_Flag = true;

      for (i=0; i<6; i++) if (mac->addr2[i] != targetMAC1[i]) MAC_Matching_Flag = false;

      if (!MAC_Matching_Flag) return; //No hit
      if (millis() - lastMillis < 7000) return; //In order to avoid a duplicate detection; 1 cycle needs around 6,500 msec.

      lastMillis = millis();     

      //Handle it.
      wifi_promiscuous_enable(0);  //イベントを取得したら、WiFi接続のためにプロミスキャスモードを中断。
      willSend = true;
    } else {
      //Data Packet
      struct sniffer_buf *sniffer = (struct sniffer_buf*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      return;
    }
}

void slack_submit(String message)
{
  WiFiClientSecure client;

  // connect
  if (!client.connect(kSlackHost, kHttpsPort)) { 
    Serial.println("slack connection failed");
  } else {
    Serial.println("slack connection: OK");    
  }

  // SSL Certificate finngerprint for the host
  const char* fingerprint = "‎‎‎‎ab f0 5b a9 1a e0 ae 5f ce 32 2e 7c 66 67 49 ec dd 6d 6a 38";
  // verify the signature of the ssl certificate
 if (client.verify(fingerprint, kSlackHost)) {
   Serial.println("ssl cert matches");
 } else {
   Serial.println("ssl cert mismatch");
 }

  // submit
  String payload="payload={\"channel\": \"" + kSlackChannel + "\", \"username\": \"" + kSlackUsername 
  + "\", \"text\": \"" + message + "\", \"icon_emoji\": \":ghost:\"}";
  Serial.println(payload.c_str());

  client.print("POST ");
  client.print(kSlackUrl);
  client.println(" HTTP/1.1");
  client.print("Host: ");
  client.println(kSlackHost);
  client.println("User-Agent: ArduinoIoT/1.0");
  client.println("Connection: close");
  client.println("Content-Type: application/x-www-form-urlencoded;");
  client.print("Content-Length: ");
  client.println(payload.length());
  client.println();
  client.println(payload);

  // リクエストを受け取る前に5秒以上は待った方がいいらしい
  delay(7000);

  while(client.available()) {
    String line = client.readStringUntil('\r');
    Serial.println(line);
  }
  client.stop();
}

void WiFi_setup()
{
  WiFi.begin(kSsid, kPass);
  while( WiFi.status() != WL_CONNECTED) {
    delay(500); // msec
  }
  Serial.println(WiFi.localIP());
}

void setup() {  
  WiFi.disconnect(); // to avoid WDT reset

  Serial.begin(115200);
  wifi_set_opmode(STATION_MODE);
  wifi_set_channel(channel);
  wifi_set_promiscuous_rx_cb(promisc_cb);

  Serial.println("Ready");

  WiFi_setup();
  slack_submit("Amazon Dash Detect Start");
  Serial.println("submitted to Slack.");
  WiFi.disconnect();

  // Start!
  wifi_promiscuous_enable(1);
}

void loop() {
  delay(100); // msec

  if (!willSend) {
    return;  
  }
  willSend = false;

  Serial.println("ADB push detected.");

  // submit to slack
  WiFi_setup();
  slack_submit("Amazon Dash Pushed (ESP8266)");
  Serial.println("submitted to Slack.");

  WiFi.disconnect();

  wifi_promiscuous_enable(1);  //プロミスキャスモード再開
}

qiita.png

v0.3 > promiscuousモード 20% duty cycle

promiscuousモードでの消費電力を抑えることにした。
ずっとpromiscuousモードにするのは無駄なので、20% duty cycleでpromiscuousモードをONにすることにした。

code

主な変更は loop()内 (以下のcnt関連の処理追加)。

  if (cnt == 5) {
    cnt = 0;
    wifi_promiscuous_enable(1);  //プロミスキャスモード再開
//    Serial.println("on");
  } else {
    wifi_promiscuous_enable(0);  //プロミスキャスモード停止    
//    Serial.println("off");
  }

以下がv0.3のコード。

esp8266_161231_slackAmazonDash.ino
#include <ESP8266WiFi.h>
#include "ESP_Promiscuous.h"
#include "dashConfig.h"
#include "WifiConfig.h"
#include "slackConfig.h"

/*
 * v0.3 Jan. 04, 2017
 *   - use promiscuous mode with 20% duty cycle
 * v0.2 Dec. 31, 2016
 *   - slack_submit() takes [message] arg
 * v0.1 Dec. 31, 2016
 *   - add [slack submit] feature
 *   - add [Wifi connection] feature
 *   - add [amazon dash detection] feature
 */

extern "C" {
  #include <user_interface.h>
}

byte channel = 5;  //WiFi channel (1-13)

unsigned long lastMillis = 0;

static const char *kSlackHost = "hooks.slack.com";
static const int kHttpsPort = 443;

boolean willSend = false;

static void ICACHE_FLASH_ATTR promisc_cb(uint8_t *buf, uint16_t len)
{  
    if (len == 12){
      //No accurate information about MAC address and length of the head of packet.
      struct RxControl *sniffer = (struct RxControl*) buf;
      return;
    } else if (len == 128) {
      //Management Packet
      struct sniffer_buf2 *sniffer = (struct sniffer_buf2*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      int i;
      boolean MAC_Matching_Flag = true;

      for (i=0; i<6; i++) if (mac->addr2[i] != targetMAC1[i]) MAC_Matching_Flag = false;

      if (!MAC_Matching_Flag) return; //No hit
      if (millis() - lastMillis < 7000) return; //In order to avoid a duplicate detection; 1 cycle needs around 6,500 msec.

      lastMillis = millis();     

      //Handle it.
      wifi_promiscuous_enable(0);  //イベントを取得したら、WiFi接続のためにプロミスキャスモードを中断。
      willSend = true;
    } else {
      //Data Packet
      struct sniffer_buf *sniffer = (struct sniffer_buf*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      return;
    }
}

void slack_submit(String message)
{
  WiFiClientSecure client;

  // connect
  if (!client.connect(kSlackHost, kHttpsPort)) { 
    Serial.println("slack connection failed");
  } else {
    Serial.println("slack connection: OK");    
  }

  // SSL Certificate finngerprint for the host
  const char* fingerprint = "‎‎‎‎ab f0 5b a9 1a e0 ae 5f ce 32 2e 7c 66 67 49 ec dd 6d 6a 38";
  // verify the signature of the ssl certificate
 if (client.verify(fingerprint, kSlackHost)) {
   Serial.println("ssl cert matches");
 } else {
   Serial.println("ssl cert mismatch");
 }

  // submit
  String payload="payload={\"channel\": \"" + kSlackChannel + "\", \"username\": \"" + kSlackUsername 
  + "\", \"text\": \"" + message + "\", \"icon_emoji\": \":ghost:\"}";
  Serial.println(payload.c_str());

  client.print("POST ");
  client.print(kSlackUrl);
  client.println(" HTTP/1.1");
  client.print("Host: ");
  client.println(kSlackHost);
  client.println("User-Agent: ArduinoIoT/1.0");
  client.println("Connection: close");
  client.println("Content-Type: application/x-www-form-urlencoded;");
  client.print("Content-Length: ");
  client.println(payload.length());
  client.println();
  client.println(payload);

  // リクエストを受け取る前に5秒以上は待った方がいいらしい
  delay(7000);

  while(client.available()) {
    String line = client.readStringUntil('\r');
    Serial.println(line);
  }
  client.stop();
}

void WiFi_setup()
{
  WiFi.begin(kSsid, kPass);
  while( WiFi.status() != WL_CONNECTED) {
    delay(500); // msec
  }
  Serial.println(WiFi.localIP());
}

void setup() {  
  WiFi.disconnect(); // to avoid WDT reset

  Serial.begin(115200);
  wifi_set_opmode(STATION_MODE);
  wifi_set_channel(channel);
  wifi_set_promiscuous_rx_cb(promisc_cb);

  Serial.println("Ready");

  WiFi_setup();
  slack_submit("Amazon Dash Detect Start");
  Serial.println("submitted to Slack.");
  WiFi.disconnect();

  // Start!
  wifi_promiscuous_enable(1);
}

void loop() {
  static int cnt = 0;

  delay(100); // msec
  cnt++;

  if (cnt == 5) {
    cnt = 0;
    wifi_promiscuous_enable(1);  //プロミスキャスモード再開
//    Serial.println("on");
  } else {
    wifi_promiscuous_enable(0);  //プロミスキャスモード停止    
//    Serial.println("off");
  }

  if (!willSend) {
    return;  
  }
  willSend = false;

  Serial.println("ADB push detected.");

  // submit to slack
  WiFi_setup();
  slack_submit("Amazon Dash Pushed (ESP8266)");
  Serial.println("submitted to Slack.");

  WiFi.disconnect();

  wifi_promiscuous_enable(1);  //プロミスキャスモード再開
}

検討

ADBボタン押下の取りこぼしがないか。

20秒ごとにADBを押下することを7回繰り返してみた。
7回とも検知できた。

20% duty cycleくらいだと失敗はないのかもしれない。

v0.4 > Modem-SleepとLight-Sleepの使用

@exabugs さんの記事をもとに調べ始めた、省電力モード。
http://qiita.com/exabugs/items/9edf9e2ba8f69800e4c5

http://qiita.com/7of9/items/171a85605c08bd3b4a72
に書いたように「Modem-sleep」と「Light-sleep」のどちらがADB検知に使えるだろうか。

こういうのは実施してみるのが早い。

code

v0.4としての変更内容はsetup()に以下を追加したこと。

//  wifi_set_sleep_type(NONE_SLEEP_T);
//  wifi_set_sleep_type(MODEM_SLEEP_T);
  wifi_set_sleep_type(LIGHT_SLEEP_T);

以下がv0.4のメインファイルのコード。

esp8266_161231_slackAmazonDash.ino
#include <ESP8266WiFi.h>
#include "ESP_Promiscuous.h"
#include "dashConfig.h"
#include "WifiConfig.h"
#include "slackConfig.h"

/*
 * v0.4 Jan. 05, 2017
 *   - use [LIGHT_SLEEP_T] mode
 * v0.3 Jan. 04, 2017
 *   - use promiscuous mode with 20% duty cycle
 * v0.2 Dec. 31, 2016
 *   - slack_submit() takes [message] arg
 * v0.1 Dec. 31, 2016
 *   - add [slack submit] feature
 *   - add [Wifi connection] feature
 *   - add [amazon dash detection] feature
 */

extern "C" {
  #include <user_interface.h>
}

byte channel = 5;  //WiFi channel (1-13)

unsigned long lastMillis = 0;

static const char *kSlackHost = "hooks.slack.com";
static const int kHttpsPort = 443;

boolean willSend = false;

static void ICACHE_FLASH_ATTR promisc_cb(uint8_t *buf, uint16_t len)
{  
    if (len == 12){
      //No accurate information about MAC address and length of the head of packet.
      struct RxControl *sniffer = (struct RxControl*) buf;
      return;
    } else if (len == 128) {
      //Management Packet
      struct sniffer_buf2 *sniffer = (struct sniffer_buf2*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      int i;
      boolean MAC_Matching_Flag = true;

      for (i=0; i<6; i++) if (mac->addr2[i] != targetMAC1[i]) MAC_Matching_Flag = false;

      if (!MAC_Matching_Flag) return; //No hit
      if (millis() - lastMillis < 7000) return; //In order to avoid a duplicate detection; 1 cycle needs around 6,500 msec.

      lastMillis = millis();     

      //Handle it.
      wifi_promiscuous_enable(0);  //イベントを取得したら、WiFi接続のためにプロミスキャスモードを中断。
      willSend = true;
    } else {
      //Data Packet
      struct sniffer_buf *sniffer = (struct sniffer_buf*) buf;
      struct MAC_header *mac = (struct MAC_header*) sniffer->buf;

      return;
    }
}

void slack_submit(String message)
{
  WiFiClientSecure client;

  // connect
  if (!client.connect(kSlackHost, kHttpsPort)) { 
    Serial.println("slack connection failed");
  } else {
    Serial.println("slack connection: OK");    
  }

  // SSL Certificate finngerprint for the host
  const char* fingerprint = "‎‎‎‎ab f0 5b a9 1a e0 ae 5f ce 32 2e 7c 66 67 49 ec dd 6d 6a 38";
  // verify the signature of the ssl certificate
 if (client.verify(fingerprint, kSlackHost)) {
   Serial.println("ssl cert matches");
 } else {
   Serial.println("ssl cert mismatch");
 }

  // submit
  String payload="payload={\"channel\": \"" + kSlackChannel + "\", \"username\": \"" + kSlackUsername 
  + "\", \"text\": \"" + message + "\", \"icon_emoji\": \":ghost:\"}";
  Serial.println(payload.c_str());

  client.print("POST ");
  client.print(kSlackUrl);
  client.println(" HTTP/1.1");
  client.print("Host: ");
  client.println(kSlackHost);
  client.println("User-Agent: ArduinoIoT/1.0");
  client.println("Connection: close");
  client.println("Content-Type: application/x-www-form-urlencoded;");
  client.print("Content-Length: ");
  client.println(payload.length());
  client.println();
  client.println(payload);

  // リクエストを受け取る前に5秒以上は待った方がいいらしい
  delay(7000);

  while(client.available()) {
    String line = client.readStringUntil('\r');
    Serial.println(line);
  }
  client.stop();
}

void WiFi_setup()
{
  WiFi.begin(kSsid, kPass);
  while( WiFi.status() != WL_CONNECTED) {
    delay(500); // msec
  }
  Serial.println(WiFi.localIP());
}

void setup() {  
  WiFi.disconnect(); // to avoid WDT reset

//  wifi_set_sleep_type(NONE_SLEEP_T);
//  wifi_set_sleep_type(MODEM_SLEEP_T);
  wifi_set_sleep_type(LIGHT_SLEEP_T);

  Serial.begin(115200);
  wifi_set_opmode(STATION_MODE);
  wifi_set_channel(channel);
  wifi_set_promiscuous_rx_cb(promisc_cb);

  Serial.println("Ready");

  WiFi_setup();
  slack_submit("Amazon Dash Detect Start");
  Serial.println("submitted to Slack.");
  WiFi.disconnect();

  // Start!
  wifi_promiscuous_enable(1);
}

void loop() {
  static int cnt = 0;

  delay(100); // msec
  cnt++;

  if (cnt == 5) {
    cnt = 0;
    wifi_promiscuous_enable(1);  //プロミスキャスモード再開
//    Serial.println("on");
  } else {
    wifi_promiscuous_enable(0);  //プロミスキャスモード停止    
//    Serial.println("off");
  }

  if (!willSend) {
    return;  
  }
  willSend = false;

  Serial.println("ADB push detected.");

  // submit to slack
  WiFi_setup();
  slack_submit("Amazon Dash Pushed (ESP8266)");
  Serial.println("submitted to Slack.");

  WiFi.disconnect();

  wifi_promiscuous_enable(1);  //プロミスキャスモード再開
}

結果

Light-SleepとModem-Sleepともに、以下の試験を実施した。

  1. 20秒ごとにADBを押下する
  2. 手順1を7回繰り返す

Modem-Sleep、Light-Sleepともに7回のADB押下を検知できた。

qiita.png

Light-Sleepを採用することにした。

(追記 2017/01/07)
http://qiita.com/7of9/items/8712a71c8af267170da4
に試験結果を記載した。

Light-Sleepを使用しない場合と同じ14時間弱までの動作となった。
Light-Sleepのまま動作していない可能性がある。

本当にLight-Sleepが機能しているのか確認するには、消費電流測定が必要だ。

Link to the ESP8266 WiFi Repeater (NAT Router)

@yesnoj gave me the information of the ESP8266 WiFi Repeater on his comment.
I appreciate it.

3
4
7

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
4