[PR]記事内のアフィリエイトリンクから収入を得る場合があります

動的IPでも安心!自宅IoTシステムの安定運用を実現する技術と工夫

DIY IoT 愛好家の皆さん、こんにちは。

今日は、私が最近取り組んだ家庭内IoTシステムの改善プロジェクトについて詳しく話そう。このプロジェクトを通じて、動的なグローバルIPアドレス環境下でも安定して動作する自宅IoTシステムの構築に成功した。その過程で得た知見や技術的なポイントを皆さんと共有したい。

スポンサーリンク

背景:Blynkローカルサーバーによる家庭内IoT運用

まず、私の家庭内IoTシステムの背景について説明しよう。私は以前から、Blynkのローカルサーバーを使用して家庭内IoTを運営してきた。Blynkは、IoTデバイスの制御やモニタリングを簡単に行えるプラットフォームで、特にDIY愛好家の間で人気がある。と思うがLegasyはオワコンまたはカンストだし新しいBlynkはサブスクだから一般人にとっては敷居が高く自分は今後も使うつもりはない。

ローカルサーバーを選択した建前上の理由は主に以下の3点だ:

  1. データのプライバシー:自宅のサーバーでデータを管理できる(C国またはK国にサーバーあったら使うべきじゃない)
  2. カスタマイズ性:必要に応じてサーバーの設定を細かく調整できる(しないけど)
  3. コスト削減:IoTクラウドサービスの利用料金がかからない

このシステムは長らく問題なく動作していたが、最近になって新たな課題に直面することになった。

問題の発生:ISP変更による動的IPアドレスへの変化

数ヶ月前、私はインターネットサービスプロバイダ(ISP)を変更した。新しいISP(ビッグローブ光)は通信の品質はそれほど変わらないと思っていたが、固定IPアドレスのオプションが無くなってしまったため、動的IPアドレスを甘んじて受けざるを得なかった。
これにより、以下のような問題が発生した:

  • グローバルIPアドレスが頻繁に変更される(具体的にはルーター再起動時とその他不明なタイミング)
  • これにより外出先でアプリBlynkサーバーに接続できなくなる
  • さらに家の外にあるIoTデバイスがサーバーから断絶され遠隔操作やモニタリングが不可能になる

この状況は、家庭内IoTシステムの利便性を大きく損ねるものだった。外出先から家電を制御したり、センサーデータをチェックしたりすることができなくなってしまったのだ。

解決策の模索:動的DNS vs カスタムソリューション

問題を解決するため、まず一般的な動的DNS(DDNS)サービスの利用を検討した。DDNSは動的IPアドレスに対して固定のドメイン名を割り当てるサービスで、IPアドレスが変更されても自動的に更新される。
しかし、以下の理由からDDNSの利用は見送った。

理由:サービスは有料でしかも高い(数百円/月~)払いたくない、なんとしてもタダでやりたい!

そこで、自前のカスタムソリューションを開発することにした。この方法なら、既存のシステムを大きく変更することなく、柔軟で信頼性の高いシステムを構築できると考えたのだ。(ちょっと大げさか)

カスタムソリューションの設計

主要コンポーネント

新しいシステムは、すでに稼働中のものを含めて以下の3つの主要コンポーネントで構成されている:

  1. ラズベリーパイ上で定期的に動作するPythonスクリプト
  2. Google Spreadsheet (GSS) とGoogle Apps Script (GAS)
  3. ESP32およびESP8266デバイス用のカスタムプログラム

これらのコンポーネントが連携して動作することで、動的IPアドレス環境下でも安定したIoTシステムの運用を実現する。そしてフローは以下だ。

システム全体の動作フロー

これで、システムの全コンポーネントが揃った。全体の動作フローは以下のようになる:

  1. ラズベリーパイ上のPythonスクリプトが定期的にグローバルIPアドレスをチェックして変更あってもなくてもGASを介してGSSに新しいアドレスを記録
  2. ESP32/ESP8266デバイスが起動またはWi-Fi再接続時にGSSにアクセスし最新のIPアドレスを読み取り、デバイス内で記憶
  3. 取得し記憶したIPアドレスを使用してBlynkサーバーに接続
  4. ESPデバイス内でBlynkサーバーとの切断を検知したら2からのフローを実行する

この仕組みにより、グローバルIPアドレスが変更されても、すべてのIoTデバイスが自動的に新しいアドレスを取得し、Blynkサーバーとの接続を維持できる。

1.定期的にグローバルIPアドレスをクラウドに保存する

ラズベリーパイ上で定期的に動作するPythonスクリプト

自宅のラズベリーパイ上で動作するPythonスクリプトは、システムの要となる部分だ。このスクリプトは以下の機能を持つ:

  • 定期的(例:60分ごと)にグローバルIPアドレスをチェック
  • IPアドレスをGSSに記録

本当はIPアドレスに変更があった場合だけ上書きというのが良かったが面倒だから定期的に上書きしてしまうことにした。どちらにしても最終チェック日時は記録しておかないと正常動作しているかわからないから良しとする。

グローバルIPアドレスの取得には、外部サービス(例:ipify API)を利用する。これにより、NAT環境下でも正確なグローバルIPアドレスを取得できる(?)らしい。←NAT環境下でも正確なってところは理解しきれていない。
Pythonスクリプトの概略は以下のようになる:

import requests
import datetime
from google.oauth2 import service_account
from googleapiclient.discovery import build

# Google Sheets APIの認証情報ファイル(JSON)のパス
SERVICE_ACCOUNT_FILE = 'credentials.json'

# スプレッドシートIDとシート名
SPREADSHEET_ID = 'YOUR_SPREADSHEET_ID'
SHEET_NAME = 'Sheet1'

# Google Apps ScriptのWebアプリケーションURL
GAS_URL = 'https://script.google.com/macros/s/GASをデプロイしたときに生成されるキー/exec'

# 認証とAPIクライアントの生成
SCOPES = ['https://www.googleapis.com/auth/spreadsheets']
creds = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES)
service = build('sheets', 'v4', credentials=creds)

def get_public_ip():
    # 公開IPアドレスを取得する
    ip = requests.get('https://api.ipify.org').text
    return ip

def send_ip_to_google_script(ip_address):
    # IPアドレスを分割してパラメータとして送信
    params = {
        'gIPAdd0': ip_address.split('.')[0],
        'gIPAdd1': ip_address.split('.')[1],
        'gIPAdd2': ip_address.split('.')[2],
        'gIPAdd3': ip_address.split('.')[3],
    }

    response = requests.get(GAS_URL, params=params)

    if response.status_code == 200:
        print('IPアドレスをGoogle Scriptに送信しました:', response.text)
    else:
        print('エラー:', response.status_code, response.text)

def write_ip_and_timestamps_to_sheet(ip_address):
    # IPアドレスを分割してA1〜D1に書き込む
    ip_segments = ip_address.split('.')
    sheet_range = f'{SHEET_NAME}!A1:F1'  # 書き込むセルの範囲を指定

    # 現在の日付と時刻を取得
    now = datetime.datetime.now()
    acquisition_date = now.strftime('%Y-%m-%d')
    acquisition_time = now.strftime('%H:%M:%S')

    # 書き込む値の準備
    values = [
        [ip_segments[0], ip_segments[1], ip_segments[2], ip_segments[3], acquisition_date, acquisition_time]
    ]

    value_range_body = {
        'values': values
    }

    # Google Sheetsに書き込むリクエストを作成
    request = service.spreadsheets().values().update(
        spreadsheetId=SPREADSHEET_ID,
        range=sheet_range,
        valueInputOption='RAW',
        body=value_range_body
    )

    # 書き込みリクエストを実行
    try:
        response = request.execute()
        print('A1〜F1に書き込みが完了しました。')
    except Exception as e:
        print(f'A1〜F1の書き込み中にエラーが発生しました: {e}')

    # A2〜F2に読み込んだ値を書き込む
    write_values_to_range = f'{SHEET_NAME}!A2:F2'  # 読み込むセルの範囲を指定

    # 書き込む値の準備
    values = [
        [ip_address, acquisition_date, acquisition_time]
    ]

    value_range_body = {
        'values': values
    }

    # Google Sheetsに書き込むリクエストを作成
    request = service.spreadsheets().values().update(
        spreadsheetId=SPREADSHEET_ID,
        range=write_values_to_range,
        valueInputOption='RAW',
        body=value_range_body
    )

    # 書き込みリクエストを実行
    try:
        response = request.execute()
        print('A2〜F2に書き込みが完了しました。')
    except Exception as e:
        print(f'A2〜F2の書き込み中にエラーが発生しました: {e}')

    # GSSのA2〜D2を読み込む
    read_ip_from_sheet()

def read_ip_from_sheet():
    # Google SheetsからIPアドレスを読み込む
    sheet_range = f'{SHEET_NAME}!A2:F2'  # 読み込むセルの範囲を指定

    # Google Sheetsから読み込みリクエストを作成
    result = service.spreadsheets().values().get(
        spreadsheetId=SPREADSHEET_ID,
        range=sheet_range
    ).execute()

    values = result.get('values', [])
    if not values:
        print('No data found.')
    else:
        print('取得したIPアドレスとタイムスタンプ:')
        for row in values:
            print(', '.join(row))

if __name__ == '__main__':
    # 公開IPアドレスを取得
    ip_address = get_public_ip()
    print(f'現在のグローバルIPアドレスは: {ip_address}')

    # Google SheetsにIPアドレスとタイムスタンプを書き込む
    write_ip_and_timestamps_to_sheet(ip_address)

このスクリプトは、crontabなどを使って定期的に実行するよう設定する。

▼これを実行して得られる結果はGoogle SpreadsheetのA1~D1にIPv4の十進数アドレスが記録される。

GSSにBlynkサーバーIPアドレス記録

上記のxxxは伏せ字で処置しているが実際には生の数値が入る。またA2には4つの数値がドットで区別され並んだ状態で入る。自分の場合はA3~D3にも入れてしまっているが気にしないでほしい。

cron(定期実行タスク)の定義方法についてはこちらの記事を参照してほしい。

 Google SpreadsheetとGoogle Apps Scriptで保持

Google Spreadsheet (GSS) は、最新のIPアドレスを保存するシンプルなデータベースとして機能する。A1,B1,C1,D1セルに最新のIPアドレスが記録される。ちなみにIPv4だ。IPv6についてはまだ勉強中でわからん。

Google Apps Script (GAS) は、GSSへの書き込みアクセスを管理する。このプログラムをどこに配置するんじゃ?って人はググってください。簡単にいうとGSS画面のメニュー[拡張機能]から開く。

▼データ書き込み用スクリプト:Pythonスクリプトからのリクエストを受け取り、GSSにIPアドレスを記録する。

function doGet(e) {
  var spreadsheetId = 'スプレッドシートを新規につくったときに生成されるURLから取る';
  var sheetName = 'シート名を写す';

  // パラメータからIPアドレスとタイムスタンプを取得
  var params = {
    "gIPAdd0": e.parameter.gIPAdd0,
    "gIPAdd1": e.parameter.gIPAdd1,
    "gIPAdd2": e.parameter.gIPAdd2,
    "gIPAdd3": e.parameter.gIPAdd3,
  };

  var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(sheetName);

  if (!sheet) {
    return ContentService.createTextOutput('Sheet not found');
  }

  // A1〜D1にIPアドレスを設定
  sheet.getRange('A1').setValue(params.gIPAdd0);
  sheet.getRange('B1').setValue(params.gIPAdd1);
  sheet.getRange('C1').setValue(params.gIPAdd2);
  sheet.getRange('D1').setValue(params.gIPAdd3);

  // 現在の日付と時刻を取得してE1, F1に設定
  var now = new Date();
  var formattedDate = Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd');
  var formattedTime = Utilities.formatDate(now, Session.getScriptTimeZone(), 'HH:mm:ss');
  sheet.getRange('E1').setValue(formattedDate);
  sheet.getRange('F1').setValue(formattedTime);

  return ContentService.createTextOutput('Success');
}

上記プログラム内には自分のグローバルIPアドレスをスプレッドシートのA1,B1,C1,D1に記録する機能のほか、タイムスタンプだとかちょっとした記録もしておくようにしてあるが不要なら削除してほしい。

どっちにしてもAIにつくってもらったスクリプトなので自分ではあまり内容の説明ができないところが情けないが、自分でいまいち理解しきれなくてもこんなの作れてしまう現代ってすごい。

    このスクリプトは、GASのウェブアプリケーションとして公開(デプロイ)し、専用URLを取得する。このURLは後でESPデバイスのプログラムで使用する。

    ESPデバイスでの動き:GSSからIPアドレス取得して接続

    概要

    従来はESP32/ESP8266デバイスが起動またはWi-Fi再接続後に、ハードコーディングされていたIPアドレスに接続していたが、今後はGSSにアクセスし最新のIPアドレスを読み取りデバイス内で記憶する。

    ただしこれは外部に配置しているデバイスでの話であって、家庭内LAN(Blynkサーバーと同一のLAN)内にある場合は行わない。

    IoTデバイス(ESP32やESP8266)用のプログラムを修正する。これらのデバイスは、起動時やWi-Fi再接続時に以下の処理を行う:0はあらかじめやっておくだけで毎回の必要はない

    1. Google CloudのSpreadsheet アクセス用のAPI Keyを取得しておく(GASウェブアプリケーション作ったときのデプロイIDとは違う話)
    2. Google CloudのAPI Keyを利用しGSSからBlynkサーバーIPアドレスを取得
    3. 取得したIPアドレスを使用してBlynkサーバーに接続

    API Key取得

    ここの情報はGoogleのサービス画面での話だが頻繁に画面外観が変わるのでスクリーンショットを貼っておいてもあまり意味がないと考え要点だけ残しておく。おおよその理解が進めばこれでいける。

    1. https://console.cloud.google.com/ にアクセスする
    2. 新しいプロジェクトをつくる
    3. たぶん左のメニューまたはダッシュボードのクイックアクセスあたりからAPIとサービスを選択する
    4. サービス一覧が出るのでGoogle Sheet APIを選択する
    5. どこかに「有効にする」があるので有効にする
    6. 次にどこかで認証情報があるはずなので開きAPI作成を行う
    7. APIが作れたらAPI Keyをコピーする(30文字ぐらいの英数字)

    このAPIキー情報とスプレッドシートIDがわかっていればGSSからデータを読み込みできる

    ESP32デバイス用のカスタムプログラム

    ESP32用のArduinoスケッチの一部を以下に示す:
    このプログラムにより、ESPデバイスは常に最新のBlynkサーバーIPアドレスを使用してサーバーに接続できるようになる。

    #include <WiFiClientSecure.h>
    #include <ArduinoJson.h>  // ArduinoJsonライブラリをインクルード
    
    
    const char* api_key = "YOUR_API_KEY";
    const char* spreadsheet_id = "YOUR_SPREADSHEET_ID";
    const char* range = "Sheet1!A1:D1";  // データを取得する範囲
    WiFiClientSecure client;
    
    int fetchGoogleSheetsData(int data[])
    {
      int error_code = 0;
      if (WiFi.status() == WL_CONNECTED) {
        WiFiClientSecure client;
        client.setInsecure(); // 証明書検証を無効化(テスト用)
    
        HTTPClient https;
        String url = String("https://sheets.googleapis.com/v4/spreadsheets/") + spreadsheet_id + "/values/" + range + "?key=" + api_key;
    
        https.begin(client, url);
        int httpCode = https.GET();
       
        if (httpCode > 0) {
          String payload = https.getString();
          Serial.println(payload);
    
          // JSON解析
          StaticJsonDocument<1024> doc;
          DeserializationError error = deserializeJson(doc, payload);
          if (!error) {
            const JsonArray& values = doc["values"][0];
            for(int i = 0; i < 4; i++){
              data[i] = values[i].as<int>();
            }
          } else {
            Serial.print("JSON parse error: ");
            Serial.println(error.c_str());
            error_code = 1;
          }
        } else {
          Serial.printf("HTTP GET failed, error: %s\n", https.errorToString(httpCode).c_str());
          error_code = 1;
        }
       
        https.end();
      }else{
        Serial.println("WiFi not connected.");
        error_code = 1;
      }
      return error_code;
    }

    ESP8266デバイス用のカスタムプログラム

    ESP8266用のスケッチは以下。ESP32とESP8266は使うライブラリが違うのでコードも多少違っていて共通化はちょっとしにくい。

    #include <WiFiClientSecureBearSSL.h>
    #include <ArduinoJson.h>  // ArduinoJsonライブラリをインクルード
    
    
    const char* api_key = "YOUR_API_KEY";
    const char* spreadsheet_id = "YOUR_SPREADSHEET_ID";
    const char* range = "Sheet1!A1:D1";  // データを取得する範囲
    WiFiClientSecure client;
    
    int fetchGoogleSheetsData(int data[])
    {
      int error_code = true;
      if (WiFi.status() == WL_CONNECTED) {
       
       std::unique_ptr<BearSSL::WiFiClientSecure> client(new BearSSL::WiFiClientSecure);
        client->setInsecure(); // 不正確な証明書を許可
    
        HTTPClient https;
        String url = String("https://sheets.googleapis.com/v4/spreadsheets/") + spreadsheet_id + "/values/" + range + "?key=" + api_key;
     
        https.begin(*client, url);
     
        int httpCode = https.GET();
       
        if (httpCode > 0) {
          String payload = https.getString();
          Serial.println(payload);
    
          // JSON解析
          const size_t capacity = JSON_OBJECT_SIZE(1) + JSON_ARRAY_SIZE(1) + 4*JSON_OBJECT_SIZE(1) + 320;
          DynamicJsonDocument doc(capacity);
          DeserializationError error = deserializeJson(doc, payload);
          if (!error) {
            const JsonArray& values = doc["values"][0];
            for (int i = 0; i < 4; i++) {
              data[i] = values[i].as<int>();
            }
            for (int i = 0; i < 4; i++) {
              Serial.printf("data[%d]: %d\n", i, data[i]);
              delay(1);
            }
          } else {
            Serial.print("JSON parse error: ");
            Serial.println(error.c_str());
            error_code = false;
          }
        } else {
          Serial.printf("HTTP GET failed, error: %s\n", https.errorToString(httpCode).c_str());
          error_code = false;
        }
       
        https.end();
      } else {
        Serial.println("WiFi not connected.");
        error_code = false;
      }
      return error_code;
    }

    共通の部分

    自分はWi-Fiマルチで想定されるSSIDをあらかじめ複数コーディングしておいて接続するようにしているが単独のSSID決め打ちでやってもよい。

    #define SERVER_IP_LOCAL IPAddress(192,168,*,*) //←BlynkサーバーのIPアドレス*部も正確に入れる
    #define SERVER_IP_PUBLI IPAddress(*,*,*,*) //←もはや形骸化であるが一応残す
    
    // for WIFIMULTI
    const char *ssid_name[] = {
       "デフォルトSSID" //←1番目にデフォルト
      ,"接続する可能性のあるSSID_1"
      ,"接続する可能性のあるSSID_2"
    };
    const char *passwords[] = {
       "デフォルトSSIDのパスワード"
      ,"接続する可能性のあるSSID_1のパスワード",
      ,"接続する可能性のあるSSID_2のパスワード"
    };
    #define ROUTERS sizeof(ssid_name)/sizeof(ssid_name[0])
    
    void BlynkConnect()
    {
      IPAddress localIP = WiFi.localIP();
      IPAddress ServerIPAddr;
      String ipString = localIP.toString();
      String leftThreeChars = ipString.substring(0, 3);
    
      //同一LAN内か判断している
      if((strcmp(WiFi.SSID().c_str(),ssid_name[0])==0)&&(leftThreeChars.equals("192"))){
        ServerIPAddr = SERVER_IP_LOCAL;//同一SSID内のときはローカルIPアドレスを指定
      }else{
        int ServeripAdd[4];
        if(fetchGoogleSheetsData(ServeripAdd)){
          // GSSから正常にIPアドレスが取れたとき
          ServerIPAddr = IPAddress(ServeripAdd[0],ServeripAdd[1],ServeripAdd[2],ServeripAdd[3]);
        }else{
          ServerIPAddr = SERVER_IP_PUBLI;//ハードコーディングしているグローバルIPアドレスだがこれを嵌めても意味はない
        }
      }
      Blynk.config(AUTH, ServerIPAddr, 8080);
    }

    loop()していて定期的にBlynkとの接続をチェックして途切れたかなと思ったらBlynkConnect()を呼べばまた新しいサーバーIPアドレスで再接続できる。

    システムの利点と課題

    今更くどいけど、このカスタムソリューションには、以下のような利点がある:

    1. 完全に自前のシステムなので、外部サービスへの依存が少ない
    2. 既存のBlynkベースのIoTシステムを大きく変更せずに対応可能
    3. IPアドレスの更新と取得が自動化されているため、メンテナンスが容易
    4. Google Spreadsheetを使用しているため、IPアドレスの履歴管理もやろうと思えば可能

    一方で、以下のような課題や改善点も考えられる:

    1. ラズベリーパイの常時稼働が必要←あたりまえだサーバーってそういうもんだ
    2. Google Cloud Platformの設定やGASの利用にやや技術的なハードルがある←根気さえあればできる
    3. ESPデバイスのプログラムにGASのURLをハードコードしているため、URLが変更(新規デプロイ)された場合に再プログラミング&コンパイルが必要←今までのIPアドレス変更ほどの頻度ではない
    4. !重要!スマホアプリでは依然として手動で入れ直さないと使えないというのは変わらない

    これらの課題については、今後のアップデートで対応していきたいところだがたぶんやらないだろう。

    アプリに関しては自動的に変わるようにするにはそれこそDDNSを契約しないと無理そう。

    まとめと今後の展望

    このプロジェクトを通じて、動的IPアドレス環境下でも安定して動作させることで自宅IoTシステムをさらに発展させることができた。固定グローバルIPアドレスがなくても、自前でBlynk IoTシステムを安定的に運用できるようになったのは大きな成果だ。

    今後の展望としては、以下のような改善や機能追加を検討している:

    1. セキュリティの強化:通信の暗号化やアクセス制御の実装←うそですやりません
    2. 冗長性の向上:複数のIPアドレス取得サービスの利用←やりません
    3. ユーザーインターフェースの改善:IPアドレス変更履歴の可視化←たぶんやらない
    4. 他のIoTプラットフォームへの対応:Blynk以外のシステムでも利用可能に←これはありうる

    最後の4番についてだが、GSSを通して情報をESPデバイス同士あるいはスマホとも交互にやりとりできるのならBlynkサーバーいらなくなるんじゃね?って思った。

    まあこのアイデアに手を付けるのは相当暇になってからになるかなと思うけど。

    ・・・

    DIYスマートホーム愛好家の皆さん、このようなアプローチを参考に、ぜひ自分だけの柔軟で安定したIoTシステムを構築してみてほしい。固定IPアドレスがなくても、工夫次第で素晴らしいスマートホームを実現できるはずだ。

    皆さんの家庭内IoTプロジェクトについても、ぜひSNSでシェアしてほしい。どのような課題に直面し、どのように解決したのか、みんなで知恵を出し合えればと思う。←っていうかAIに聞けばほとんど解決してしまえる時代だ。

    それでは、素晴らしいIoTライフを!

    あとがき

    システムの構想は自分でやったが、プログラムのコーディングはChatGPTほかいくつかのAIに聞きながら作った。

    さらにこの記事も別のAIサービスに要点だけ与えて書かせたものを自分で加筆修正した。要点だけ与えればAIが記事を書いてくれるいい時代すぎるな。

    いかにAIをうまく使いこなせるかが今後の生死を分かつものと痛感する。

    さらに今回の動的IPアドレスへの対応というのは今後の展開として自動車の中にESP32デバイスを配置するつもりでいるのでそうなったとき再コンパイル&デバイスへのアップロードが面倒だなあと思っていたから。

    これでIPアドレスが変わってもデバイス書き換えする必要がなくなってその点はスゲー楽になる。

    B081YD3VL5
    【国内正規代理店品】Raspberry Pi4 ModelB 4GB ラズベリーパイ4 技適対応品【RS・OKdo版】

    ¥9,400(2024/06/28 15:32時点の価格)
    平均評価点:5つ星のうち4.4
    >>楽天市場で探す
    >>Yahoo!ショッピングで探す

    ラズベリーパイもひところに比べたらずいぶん値上がりしてしまって手に入れにくいな。

    B086QKRY25
    WayinTop ESP32開発ボード Wi-Fi + BLEモジュール ESP-WROOM-32実装済み デュアルコア 技適取得済み 2個入り 専用USBケーブル付き

    ¥2,180(2024/06/28 15:34時点の価格)
    平均評価点:5つ星のうち4.2
    >>楽天市場で探す
    >>Yahoo!ショッピングで探す

    ESP32も1個1000円を超える。

    タイトルとURLをコピーしました