LoginSignup
6
3

More than 1 year has passed since last update.

さくらのモノプラットフォームを試してみた

Last updated at Posted at 2022-05-05

記事の概要

さくらのモノプラットフォームはIoTシステムの構築を簡単に行うためのサービスです。

どんなサービスであるかはサイトにて説明されていますが、具体的にどういうものなのか、本当に簡単にIoTシステムができるのかは分かりませんでした。
そこで、具体的にサービスを利用して確かめてみることにしました。

さくらのモノプラットフォームは2022年5月の現時点ではまだβ版であり、開発キットの配布は法人のみを対象としています。
ですが、いずれは一般販売もされると思われますので、ここで紹介したく思います。

なお本記事は私が勝手にやっていることなので、さくらインターネット株式会社さんは何の関係もありません。

準備

さくらのモノプラットフォームはLTE通信モジュールとしてNordic社のnRF9160を使用しています。
M5StackもしくはSTM32 Nucleo F411REと接続できる開発キットが用意されています。この開発キットは法人限定で無料配布されています。
さくらに会員登録して、「開発キットお申し込みフォーム」から申請します。
本記事では、この手順の説明は省略します。

以前にLTEモジュールのEC21-Jをマイコン制御した時は、EC21-JのUART端子をマイコンに接続するのが面倒でした。
それに比べて、基板1つで市販の開発ボードにLTEモジュールを接続できるのは便利なので、将来は一般販売してほしいと思いました。

本記事においては、STM32 Nucleo F411REを使用したサンプルを試します。

M5stackも試したかったのですが、M5stack用の基板にはnRF9160を直接はんだ付けする必要があり、M5StackもしくはSTM32 Nucleoのどちらかでしか試せないようになっていました。

準備は以下のサイトの「ご利用の流れ」に従って行います。

1. 開発キットの組み立て

IMG-0739.jpg

秋月電子などからサンプル基板取扱説明書 の『2.必要な部材について』の部品を購入して開発キットにはんだ付けします。
J-Link Baseなどの20ピンコネクタを使用する場合は、変換基板用の部品、J-Link変換基板取扱説明書の『4.2. J-LINK コネクタ変換基板を利用した接続(ブレッドボードなど)』の部品を購入するのも忘れないようにご注意ください。

はんだ付けに慣れていない人には、1.27mmピッチのはんだ付けは少しだけ難しいかもしれません。

2. さくらのクラウドへの登録

次に以下の「さくらのクラウド」のアカウント作成を行います。

さくらのクラウド登録画面.png

アカウント作成後はさくらのクラウドホームの「セキュアモバイルコネクト」をクリックします。

さくらのクラウドホーム.png

3. SIMの作成

以下のマニュアルに従い、SIMの作成を行います。

『3. さくらのセキュアモバイルコネクトでのSIMの作成』

追記することは特にないので、説明は省略します。

4. プロジェクト作成とSIMとの紐づけ

以下のマニュアルに従い、プロジェクト作成を行い、先ほどに作成したSIMと紐づけます。

『4. SIMのプロジェクトへの紐付け』

追記することは特にないので、説明は省略します。

5. STM32へのファームウェア書き込み

以下のマニュアルに従い、STM32 Nucleoにファームウェアを書き込みます。

『6. サンプルプログラムの書き込み』

STM32 Nucleoに以下の『SIPF Client for Nucleo リリース一覧』からダウンロードしたファイルを書き込みます。

SIPF Client for Nucleo リリース一覧

ファイルはTX用、RX用、FPUT用の3種類があります。動作確認ではTX用とRX用の2つを使います。

まずはSTM32をPCとUSB接続します。
次にTeraTermなどのターミナルソフトを立ち上げます。通信速度は115200に設定します。
image.png
image.png

以下のようなNOD_F411REドライバの中にbinファイルをドラッグ&ドロップすることで書き込みが行えます。
次のステップでは、クラウドへの送信テストを行うので、TXサンプルプログラム、フォルダSAMPLE_TXのbinファイルを書き込んでください。

image.png

書き込みに成功するとTeraTermに+++ Ready +++のメッセージが表示されます。

もしくは『SIPF Client for Nucleo GitHubリポジトリ』からサンプルプログラムのソースコードをダウンロードし、STM32CubeIDEでプロジェクトファイルを開き、ビルドして書き込むこともできます。
image.png

nRF9160のファームウェア更新について

nRF9160については出荷時点のファームウェアをそのまま使用するので、ファームウェア更新はしませんでした。
もし更新したければ、STM32 Nucleo用のサンプルプログラムを用いれば、ネットワークからnRF9160の自動更新が行えます。

『8-3. SCM-LTEM1NRFファームウェアのFOTA実行方法』

もしくは以下の『nRF9160ファームウェア リリース一覧 』からSCM-LTEM1NRF / SCO-M5NRF9160用のhexファイルをダウンロードして、J-LinkとNorodic社のツールnRF Connect for Desktopなどを用いて書き込むこともできます。

『nRF9160ファームウェア リリース一覧 』

6. Websocketサービスアダプタの作成

以下のマニュアルに従い、Websocketサービスアダプタを作成します。

『7-1. Websocketサービスアダプタの作成』(https://manual.sakura.ad.jp/cloud/iotpf-beta/getting-started/gs-scmltem1nrf-beta.html#id11)

追記することは特にないので、説明は省略します。

7. 送信試験

以下のマニュアルに従い、送信試験を行います。

『7-2. 送信の確認』

「モノプラットフォーム」の「サービスアダプタ」を選択し、先ほどに作成したサービスアダプタをクリックするか、チェックボックスを選択して、右上の「詳細」をクリックしてください。

TXサンプルプログラムはSTM32 Nucleoの青色のスイッチを押下すると、テスト送信が開始されます。
TeraTermとサービスアダプタのペイロードに送信データが表示されます。

さくらのクラウド画面.png

8. 受信試験

以下のマニュアルに従い、受信試験を行います。

『7-3. 受信の確認(ターミナルで確認)』

『7-3. 受信の確認(基板上のLEDで確認)』

試験前に、RXサンプルプログラム、フォルダSAMPLE_RXのbinファイルを書き込んでください。
書き込み方法はTXサンプルプログラムの時と同様です。

そして先ほどと同様に、「モノプラットフォーム」の「サービスアダプタ」を選択し、先ほどに作成したサービスアダプタをクリックするか、チェックボックスを選択して、右上の「詳細」をクリックしてください。

右上にある「メッセージ送信」をクリックして、メッセージ送信画面から、宛先、タグ、タイプ、値を入力して送信します。

  • 宛先 : 先ほど作成したSIMを選択
  • タグ : 00からFFの任意の値を選択
  • タイプ : 任意のタイプを選択
  • 値 : タイプの値域を満たす任意の値を選択

送信後、STM32 Nucleoの青色のスイッチを押下すると、TeraTermに送信データが表示されます。
これはSTM32がクラウドから送信されたデータを受信したことを意味します。

次にタグに4c、タイプを値に0以外のという値を入力して、データ送信してください。

  • 宛先 : 先ほど作成したSIMを選択
  • タグ : 0x4cをLED制御に割り当てているので、「4c」と入力
  • タイプ : 「8ビット符号なし整数」を選択
  • 値 : LED制御値
    • 0:LED OFF
    • 1からFF:LED ON

送信後、STM32 Nucleoの青色のスイッチを押下すると、STM32 Nucleoの緑色のLEDが点灯します。
先ほどの値を0にしたものを送信し、再度、青色のスイッチを押下すると、STM32 Nucleoの緑色のLEDが消灯します。

サンプルプログラムのソースコード

サンプルプログラムはGitHubからダウンロードできます。
STM32 NucleoとnRF9160それぞれについて用意されています。

『nRF9160ファームウェア GitHubリポジトリ』

『SIPF Client for Nucleo GitHubリポジトリ』

ここではSTM32 Nucleoのサンプルプログラムの中身を確認してみます。

モード選択

STM32のサンプルプログラムにはTX用、RX用、FPUT用の3種類ありましたが、それは以下の定義で選択します。
TX用ならばSAMPLE_TX、RX用ならばSAMPLE_RX、FPUT用ならばSAMPLE_FPUTを定義します。

main.h
#define AUTH_MODE	(1)
/* Select application */
#if !defined(SAMPLE_TX) && !defined(SAMPLE_RX) && !defined(SAMPLE_FPUT)
/* ビルド構成で未設定なら */
#define SAMPLE_TX	//デフォルトはTXサンプルアプリ
#endif

初期メッセージ

リセット後にTeraTermに表示されるメッセージは以下の部分になります。

main.c
  /* USER CODE BEGIN 2 */
  print_msg("*** SIPF Client for Nucleo(");
#if defined(SAMPLE_TX)
  print_msg("TX SAMPLE APP");
#elif defined(SAMPLE_RX)
  print_msg("RX SAMPLE APP");
#elif defined(SAMPLE_FPUT)
  print_msg("FPUT SAMPLE APP");
#else
#error Please select Sample Application.(Declare SAMPLE_XX to '1' on main.h)
#endif
  print_msg(")***\r\n");

  SipfClientUartInit(&huart1);

  print_msg("Request module reset.\r\n");
  requestResetModule();

  print_msg("Waiting module boot\r\n");
  print_msg("### MODULE OUTPUT ###\r\n");
  ret = waitBootModule();
  if (ret != 0) {
      print_msg("FAILED(%d)\r\n", ret);
      return -1;
  }
  print_msg("#####################\r\n");
  print_msg("OK\r\n");

  HAL_Delay(100);

  ret = SipfReadFwVersion(&fw_version);
  if (ret != 0) {
      print_msg("SipfReadFwVersion(): FAILED\r\n");
  }


#if AUTH_MODE
  if (fw_version < 0x00040000) {    // ver.0.4.0未満なら認証モード切り替えを行う
      print_msg("Set Auth mode... ");
      ret = SipfSetAuthMode(0x01);
      if (ret != 0) {
        print_msg("FAILED(%d)\r\n", ret);
        return -1;
      }
      print_msg("OK\r\n");
  }
#endif
  SipfClientFlushReadBuff();

  print_msg("+++ Ready +++\r\n");

nRF9160とのUART通信に、どのUSARTモジュールを使用するかの指定は、SipfClientUartInit(&huart1)で設定しています。

requestResetModuleはIO端子のOUTPUT_WAKE_IN_Pinを10msecのHigh信号出力しています

main.c
static void requestResetModule(void)
{
    // Reset request.
    HAL_GPIO_WritePin(OUTPUT_WAKE_IN_GPIO_Port, OUTPUT_WAKE_IN_Pin, GPIO_PIN_SET);
    HAL_Delay(10);
    HAL_GPIO_WritePin(OUTPUT_WAKE_IN_GPIO_Port, OUTPUT_WAKE_IN_Pin, GPIO_PIN_RESET);

    HAL_Delay(200);
}

waitBootModuleはnRF9160からの応答を待機し、READY messageを受信するか、Busyやタイムアウトでエラーになると処理を終了します。

main.c
static int waitBootModule(void)
{
    int len, is_echo = 0;

    // Wait READY message.
    for (;;) {
        len = SipfUtilReadLine(buff, sizeof(buff), 65000);
        if (len < 0) {
            // ERROR or BUSY or TIMEOUT
            return len;
        }
        if (len == 0) {
            continue;
        }
        if (len >= 13) {
            if (memcmp(buff, "*** SIPF Client", 15) == 0) {
                is_echo = 1;
            }
            //Detect READY message.
            if (memcmp(buff, "+++ Ready +++", 13) == 0) {
                break;
            }
            if (memcmp(buff, "ERR:Faild", 9) == 0) {
                print_msg("%s\r\n", buff);
                return -1;
            }
        }
        if (is_echo) {
            print_msg("%s\r\n", buff);
        }
    }
    return 0;
}

また、ファームウェアバージョンの読み込みSipfReadFwVersion(&fw_version)を行っています。

認証モードについては、よく分かりませんでした。

DMAとUART通信

UART通信にはDMAを使用しています。

HALライブラリのUART受信は、何バイト受信するかをあらかじめ指定しないといけないのですが、実際に何バイトのデータが送信されてくるのかは不明です。
なので、1バイトごとに受信割り込みを発生させてデータを読み込み、何バイトのデータが送信されてきても全て受信できるようにしたりします。
ですが、この方法ではいちいち割り込み処理を発生させるので、オーバーランエラーによるデータの受信ミスが起きやすくなります。

その対策として、ここではDMAのcircularモードを使ったデータ受信を行います。
DMAとはCPUを使わずにバスを通じて周辺モジュールとメモリ間の通信を行う機能です。

DMAのcircularモードでは、データ受信にはリングバッファを用います。
初期状態ではリングバッファの読み込み位置と書き込み位置は一致させておきます。
データ受信すると、リングバッファの書き込み位置に受信データが格納され、書き込み位置が自動でインクリメントされます。
すると読み込み位置はインクリメント前の書き込み位置になります。読み込みが終われば、読み込み位置を手動でインクリメントさせます。
こうして、リングバッファの読み込み位置が書き込み位置を追いかけることにより受信データを取り込んでいきます。

詳しくは以下を参照ください。

書き込み位置はDMA_WRITE_PTRで定義されています。
書き込み位置は、DMAのポインタUART_RX_BUFF_SZ - pModUart->hdmarx->Instance->NDTRです。ただし、リングバッファが1周した場合を考えて、UART_RX_BUFF_SZの剰余を取っています。

p_readが読み取り位置です。
SipfClientFlushReadBuff関数で、読み込み位置p_readを書き込み位置DMA_WRITE_PTRと一致させます。
p_readDMA_WRITE_PTRが一致するまで無限ループを回し、SipfClientUartReadByte関数を実行するごとにp_readが加算されて、やがてDMA_WRITE_PTRと一致します。

sipf_client.c
int SipfClientUartReadByte(uint8_t *byte)
{
    if (p_read != DMA_WRITE_PTR) {
        *byte = rxBuff[p_read++];
        p_read %= UART_RX_BUFF_SZ;
        return *byte;
    } else {
        return -1;
    }
}

int SipfClientFlushReadBuff(void)
{
    uint8_t b;
    if (pModUart == NULL) {
        return -1;
    }
    while (SipfClientUartReadByte(&b) != -1);

    return HAL_OK;
}

DMA方式は受信エラー時に勝手にデータ送受信を停止してしまうという欠点があったのですが、以下のエラーハンドリング中止処理を入れることで、この問題を回避できます。

sipf_client.c
    __HAL_UART_DISABLE_IT(pModUart, UART_IT_PE);
    __HAL_UART_DISABLE_IT(pModUart, UART_IT_ERR);

スイッチ入力確認

サンプルプログラムで動作確認した時、青色のスイッチを押すと、送信したり、受信したりしていました。
そんなスイッチの押下を監視しているのが、サンプルプログラムの以下の部分です。

main.c
	        if ((prev_ps == GPIO_PIN_SET) && (ps == GPIO_PIN_RESET)) {

	        ()

            }

	        prev_ps = ps;

psが現在のスイッチの状態で、prev_psが過去のスイッチの状態です。
prev_psがHighで、psがLowならば、スイッチがHighからLowに立ち下がっているので、スイッチ押下されたことを意味します。

普通は外部割込みで立下りエッジ検出すると思ったのですが、サンプルプログラムではmain関数のループでひたする監視するポーリング方式を採用しているようです。

また、チャタリング対策らしきものも見られます。
スイッチ押下直後は、HighとLowの激しいブレが発生し、それをスイッチ押下と誤検知してしまいます。
ハード的な対策もできますが、部品コスト削減のためにソフト的な対策で済ませることもできます。
一番簡単なソフト的な対策は、スイッチ押下検知後の一定時間のスイッチ変化を無視することです。

サンプルプログラムの以下の部分がそれになります。

main.c
	    if (((int)poll_timeout - (int)uwTick) <= 0) {
	        poll_timeout = uwTick + SW_POLL_TIMEOUT;
	        ps = HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin);
	        if ((prev_ps == GPIO_PIN_SET) && (ps == GPIO_PIN_RESET)) {

例えばpoll_timeoutが200の時に、現在時間であるuwTickも200になると、if分岐してスイッチ状態を確認します。
この時、poll_timeoutuwTick + SW_POLL_TIMEOUT=200+100=300になります。
するとuwTickが300になる時間までの間は、スイッチの変化を確認せずに無視することになります。
これにより、スイッチの短時間のブレを無視することができます。

ただ、本サンプルプログラムでは、スイッチ押下検知後だけではなく、常時この無視時間を設けているのが面白いと思いました。
どういう理由で、そうしているのか是非とも知りたいところです。

送信

TXモードの場合、スイッチ押下を検知すると、TeraTermに送信予定のデータを表示し、クラウドにデータ送信します。

main.c
	#if defined(SAMPLE_TX)
	            print_msg("B1 PUSHED\r\nTX(tag_id: 0x01, type: 0x04, value: %d)\r\n", count_tx);

	            ret = SipfCmdTx(0x01, 0x04, (uint8_t*)&count_tx, 4, buff);

ターミナルでの確認用の受信

RXモードの場合、スイッチ押下を検知すると、クラウドから送信されたデータがないかを確認し、もしあればそれらのデータを受信します。

main.c
	#elif defined(SAMPLE_RX)
	            print_msg("B1 PUSHED\r\nRX\r\n");
	            SipfObjObject objs[16];
	            uint64_t stm, rtm;
	            uint8_t remain, qty;
	            ret = SipfCmdRx(buff, &stm, &rtm, &remain, &qty, objs, 16);
	            if (ret > 0) {

様々なデータタイプに対応して、TeraTermにメッセージ表示できるようになっています。

main.c
	                    switch (objs[i].type) {
	                    case OBJ_TYPE_UINT8:
	                        memcpy(v.b, p_value, sizeof(uint8_t));
	                        print_msg("%u\r\n", v.u8);
	                        break;
	                    case OBJ_TYPE_INT8:

LEDでの確認用の受信

受信データのタグobjs[i].tag_idが0x4cの場合に、LEDのON/OFF処理を実行しています。

main.c
	                    // LED2 ON/OFF サンプル
	                    if ((objs[i].tag_id == 0x4c) && (objs[i].type == OBJ_TYPE_UINT8)) {
	                        //Tag='L' で Type=uint8の場合
	                        if (*(uint8_t*)objs[i].value == 0) {
	                            // LED消す
	                            print_msg("LED2(GREEN): OFF\r\n");
	                            HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
	                        } else {
	                            // LED付ける
	                            print_msg("LED2(GREEN): ON\r\n");
	                            HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
	                        }
	                    }

料金について

利用開始前に気になるのは料金だと思いますが、料金体系については以下で説明されています。

デバイス1個につき毎月220円の基本料金がかかります。
そしてサービスアダプタを利用するとデバイス1個につき毎月11円です。
センシングデータ以外の大きなサイズのデータを通信する目的のファイル送受信機能があり、1GBごとに110円です。

SIM基本利用料毎月13円です。
通信回線の使用料は、例えばSoftbankならば1MBごとに6円です。

データを毎月100MB送信するという無茶な使い方をしても月額1000円を超えないので、テスト的に1台だけ使用する人は、そこまでお金のことを気にしなくてもいいでしょう。

しかも利用実績のない月は基本料金とサービスアダプタ代は請求されないので、テストが終了して放置している場合は料金がほとんど発生しません。
これは嬉しい制度です。

大量のIoT製品を製造してお客様にご利用いただき、通信代金はシステム管理するこちらが負担するということがよくあります。
そういう場合に長期間使用されていない製品についても月々の基本料金が発生して、それなのに「たまに使われる時の事を考えると利用停止にもできない」ということで、無駄にお金を払い続けることがあります。
ですが使用実績のある月にだけ料金が発生するならば、そんな心配は無用になります。

サンプルプログラムの修正

サンプルプログラムを修正して温湿度センサSHT31の測定データを制御します。
さくらのクラウドからの命令で測定開始し、測定結果をさくらのクラウドへ送信させます。

SHT31の制御方法については以下の記事をご参照ください。

ファームウェア

サンプルプログラムを修正して、タグ0x1f=31を受信すると測定開始するようにします

main.c
	                    if ((objs[i].tag_id == 0x4c) && (objs[i].type == OBJ_TYPE_UINT8)) {
	                        //Tag='L' で Type=uint8の場合
	                        if (*(uint8_t*)objs[i].value == 0) {
	                            // LED消す
	                            print_msg("LED2(GREEN): OFF\r\n");
	                            HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
	                        } else {
	                            // LED付ける
	                            print_msg("LED2(GREEN): ON\r\n");
	                            HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
	                        }
	                    } else if ((objs[i].tag_id == 0x1f) && (objs[i].type == OBJ_TYPE_UINT8)) {
	                		// SHT31測定開始
	                		sht31_start();
                            print_msg("Start SHT31 measurement\r\n");
	                    }

測定完了後に温度をさくらのクラウドへ送信するようにします。

sht31_ctrl.c
void sht31_read(void)
{
	int ret;
	uint32_t temperature = 0;
	uint8_t	sh31_data[6] = {0};

	if(true == sys_timer_limit[SHT31_STANDBY_TIMER])
	{
		// SHT31測定完了待機タイマ フラグ リセット
		sys_timer_limit[SHT31_STANDBY_TIMER] = false;

		// 測定値読み出し
		HAL_I2C_Master_Receive(&hi2c1, SHT31_I2C_ADDR << 1, sh31_data, 6, 1000);

		// 湿度計算
		s_rh = 100.0 * (sh31_data[3] << 8 | sh31_data[4]) / 65535.0;

		// 温度計算
		s_t = -45.0 + 175.0 * (sh31_data[0] << 8 | sh31_data[1]) / 65535.0;
		temperature = (uint32_t)s_t;

		// デバッグ用出力
		print_msg("Humidity=%d\r\n", (uint16_t)s_rh);
		print_msg("Temperature=%d\r\n", (uint16_t)s_t);

		memset(buff, 0, sizeof(buff));
        ret = SipfCmdTx(0x01, 0x04, (uint8_t*)&temperature, 4, buff);
        switch (ret) {
        case 0:
            print_msg("OK(OTID: %s)\r\n", buff);
            break;
        case -3:
            print_msg("Receive Timeout...\r\n");
            break;
        default:
            print_msg("NG\r\n");
            break;
        }
	}
}

このファームウェア修正の作業には10分ほどしかかかりませんでした。元になるファームウェアがあるので、とても簡単です。

動作確認

実際にファームウェアの動作を確認してみます。
まず、起動後に初期化メッセージが表示されます。

初期化TeraTerm.png

さくらのクラウドからタグを0x1Fに設定した、8ビット符号なし整数タイプのメッセージ送信します。

image.png

さくらのクラウドに送信を示す履歴がつきます。

送信後の表示.png

青色のスイッチを押下すると、データ受信して、測定を開始します。

受信TeraTerm.png

そして測定結果をクラウドに送信します。
ペイロードに温度の24が表示されています。

受信後の表示.png

試用感想

まだ初歩的な動作確認をしただけですが、使ってみた感想は「簡単でお手軽」というものです。
センサデータをクラウドに送信するファームウェアが10分で作成できました。
IoT機器の開発に必要な時間とコストを大幅に減らせると期待できます。

以前にEC21-Jを使用して、MQTT通信でGCPに位置情報を送信するIoT機器を作成した時は、GCPとの送受信プログラムの作成には2週間くらいかかりました。

それに比べて、本サービスでは既に用意されたプログラムを使用できたので、私がすることは何もなくて楽でした。

今後はnRF9160のGNSS機能も気楽に使えるようになることを期待しています。(現時点ではGNSS機能の対応予定はなさそうなので、自分でサンプルプログラムを修正しようと思っています。)

1台あたり月に300円程度のコストしかかからないのは、IoT化の敷居を下げてくれます。
サービスが使用されていない月には料金請求がないのも助かります。
市場に出してから、何らかの理由で利用停止した機器についてまで料金が発生するのを避けることができます。

私がまだ調べていないので、よく分かっていないのですが、GCPなどでしていたことが、本サービスでもできるかを気にしています。
例えば、1日に1000台のデバイスから位置データが約4万回の送信されるのをBigQueryに保存し、SQLでデータ抽出するようなことを私はしていました。
本サービスについて、大量にデータが送られた時に、それらをどのように処理できるのかを今後調べたいと思います。

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