2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ESP8266とSSD1306 OLEDディスプレイでリアルタイム株価と天気と時間を表示する

Last updated at Posted at 2024-08-15

はじめに

画像のようなガジェットを開発しました。手元は映っていませんが、KY-040で操作しています。
ところどころはみ出ていたりしますが、修正する気力がないのでこのままにします。

無題の動画 ‐ Clipchampで作成 (11).gif

1つ目の画面は一瞬しか映っていませんが、時計です。日付と曜日も表示します。
2つ目の画面は、1時間後の天気です。
3つ目の画面は、日経平均株価です。グラフ表示機能が付いています。
4つ目の画面は、ダウ平均株価です。これも同様グラフ表示機能が付いています。
5つ目の画面は、為替相場です。ドル円を表示しています。グラフ表示機能あり。

作成経緯

SSD1306を購入したので、何かいいものを作ろうということで作成しました。日本語表示に関しては実装しようと思いましたが、Adafruitのライブラリを使いたかったので諦めました。Adafruitライブラリにこだわらない方は以下の記事を読むと日本語表示できるかもしれません。

使用部品

使用した部品は以下の通りです、おそらく1500円以内に収まっています。

  • ESP3266 (NodeMCU 1.0 Dev kit) 3つで1800円
  • SSD1306 2つで1200円
  • KY-040 部屋に落ちていたのでわからない
  • デュポンケーブル

ESP3266

これはamazonに格安で転がっているものを使いました。

こういったプロジェクトではraspberry piがつかわれることが多いイメージですが、ラズパイアンチなのでArduinoを使用しました。ラズパイは高い。

選定理由

安くて、インターネットに接続できるという利点があるのでこちらを選定しました。BLEが必要な方はESP32-WROOMの方を購入してください。これは少し高くて1つ1000円ほど。ESP3266は3つで1800円なので、一つ600円で購入できます。

SSD1306

これは有機ELのミニディスプレイです。2色表示あるいは、単色表示の物がamazonにて激安で販売されています。
私は以下のもを使用しました。

選定理由

1インチ以下なのでかなり小さいです。簡単な情報表示のみなのでこちらを選定しました。安いしね。

KY-040

これがKY-040のamazonページです。結構高いですね。一つあるといろいろ使えそうなのでお勧めです。

選定理由

部屋に落ちていたからです。いつ買ったのかも覚えていませんが、私の部屋に落ちているということは私の物なので、使用しました。KY040ですが、回すだけでなく押すこと可能になっており押せるので、かなり使いやすいです。プレステのコントローラみたいになっています。

とりあえず、使用部品はこんな感じです。

使用しているAPI

このプロジェクトには以下のAPIを使用しています。

  • OpenWeatherAPI
  • Yahoo Finance API
  • NTP(厳密にはAPIではないが、説明簡単化のためここに記す)

OpenWeatherAPI

これはかなり有名なお天気情報を取得できるAPIです。1か月に100万回という制限があります(ほぼない)が、無茶苦茶便利です。

Yahoo Finance API

これは為替情報を取得するために利用しています。日経平均やダウ平均、為替相場の監視に使っていますが、URLをいじれば、簡単に使えます。ただ、無料版はインターバルが一日なので、ライブ性には欠けます。課金した場合細かいのを得られるの、それ前提でコードを作っていますが、

https://query1.finance.yahoo.com/v8/finance/chart/%5EN225?interval=1d

これは、日経平均を得るためのURLです。

NTPサーバー

正確な時間を取得するため、1時間ごとに一度時間を取得して校正を行っています。
ESPではarudinoなので、configTime関数を使えば簡単に取得できます。

使用ライブラリ

ESP8266WiFi.h

ESP3266をWifiに接続するためのライブラリ

Wire.h

I2C通信を行うためのライブラリ、SSD1306はI2C通信

Adafruit_GFX.h

Adafruit社が開発した、ディスプレイに描画するためのライブラリ

Adafruit_SSD1306.h

SSD1306にいろいろ表示するためのライブラリ

ArduinoJson.h

Jsonを解析するためのライブラリ

time.h

時間をいじれるライブラリ。The World関数もある。

ESP8266HTTPClient.h

Webサーバーと通信するためのライブラリ。HTTP GETやらPOSTやらいろいろ

実装

一応、一番下にソースコードを無理やり一つにまとめて置いておくので、知りたい方はそちらを見ながらやってみてください。

ピン配置

コンポーネント 機能 ピン名 GPIOピン ボード上のピン
OLEDディスプレイ (SSD1306) SDA 4 GPIO 4 D2
SCL 5 GPIO 5 D1
ロータリーエンコーダ (KY-040) CLK ENCODER_CLK GPIO 14 D5
DT ENCODER_DT GPIO 12 D6
SW ENCODER_SW GPIO 13 D7

フローチャート

WfhMTU8iSdocYPLBQmLp8r.png

もはやフローチャートというか何かわからん。。。。まま、ええわ、これから解説します。

定義とか諸々

ライブラリ

#include <ESP8266WiFi.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
#include <time.h>
#include <ESP8266HTTPClient.h>

ライブラリは、先ほど説明したとおりですね。

Wifi APIの設定

// Wi-Fi設定
const char* ssid = "SSID";
const char* password = "PASS";

// API設定
const char* weatherServer = "api.openweathermap.org";
const char* apiKey = "OpenweatherKey"; // OpenWeatherMap APIキー
const char* city = "Osaka,jp"; // 都市名と国コード

const char* nikkeiURL = "https://query1.finance.yahoo.com/v8/finance/chart/%5EN225?interval=1m";
const char* dowURL = "https://query1.finance.yahoo.com/v8/finance/chart/%5EDJI?interval=1m";
const char* usdJpyURL = "https://query1.finance.yahoo.com/v8/finance/chart/USDJPY=X?interval=1m";

これはwifi設定とかの値です。変数に格納しています。
ただ、yahoo finance APIからデータを取得する先駆者様が数名いらっしゃいましたのでそれをまねしています。違うのは株価のライブ性を重視していますので、URLのintervalを1mに設定しています。

https://query1.finance.yahoo.com/v8/finance/chart/%5EN225?interval=1m

しかし、1mに設定することで問題が発生しました。1mのデータは細かいので、120KBものデータがありました。ESPにはそんなデータ入りません。そこで、株価などのjsonを覗くとregularMarketPriceという要素があり、すべてを読み込むのをあきらめれば何とかいけます。

OLEDやらエンコーダの設定

// OLEDディスプレイの設定
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
#define ENCODER_CLK 14  // CLKピン (D5)
#define ENCODER_DT 12   // DTピン (D6)
#define ENCODER_SW 13   // SWピン (D7)

これは普通にピン設定やら画面の大きさの設定です。

それぞれ細かい変数

unsigned long lastUpdateTime = 0;  // 最後に時間を更新した時刻(ミリ秒)
const unsigned long updateInterval = 30 * 60 * 1000;  // 時間表示の更新間隔(30分ごとに更新)

unsigned long lastWeatherUpdateTime = 0;  // 最後に天気データを更新した時刻(ミリ秒)
const unsigned long weatherUpdateInterval = 60 * 60 * 1000;  // 天気データの更新間隔(1時間ごとに更新)

unsigned long lastUsdJpyUpdateTime = 0;  // 最後に為替(USD/JPY)データを更新した時刻(ミリ秒)
const unsigned long usdJpyUpdateInterval = 5 * 60 * 1000;  // 為替データの更新間隔(5分ごとに更新)

volatile int rotaryValue = 0;  // ロータリーエンコーダーの回転値(回転方向と回転量を表す)
volatile int lastEncoded = 0;  // ロータリーエンコーダーの前回のエンコード状態

int displayMode = 0;  // 表示モード(0: 時間, 1: 天気, 2: 日経, 3: ダウ, 4: 円相場)

unsigned long lastButtonPress = 0;  // 最後にボタンが押された時刻(ミリ秒)
const unsigned long debounceDelay = 200;  // ボタン押下のデバウンス時間(200ミリ秒)

float nikkeiPrices[100] = {30000};  // 日経平均株価のデータ(最新100値を保持)
float dowPrices[100] = {30000};     // ダウ平均株価のデータ(最新100値を保持)

float usdJpyPrice = 0.0;  // 現在の為替レート(USD/JPY)

float scaleFactor = 1.0;  // グラフ描画時の拡大縮小スケールファクター(0.5〜2.0の範囲)

String stockName;  // 現在表示中の株価の名前("Nikkei 225" または "Dow Jones")

String weatherMain = "N/A";  // 現在の天気のメインカテゴリ(例: "Clear", "Clouds")
String weatherDescription = "N/A";  // 現在の天気の詳細な説明(例: "overcast clouds")

float temperature = 0.0;  // 現在の気温(摂氏)
int humidity = 0;  // 現在の湿度(パーセンテージ)
int pressure = 0;  // 現在の気圧(ヘクトパスカル)
int windSpeed = 0;  // 現在の風速(メートル毎秒)
int cloudiness = 0;  // 現在の雲量(パーセンテージ)

bool weatherDataReady = false;  // 天気データが準備できているかどうかを示すフラグ

float usdJpyPrices[100] = {30000};  // 為替レート(USD/JPY)のデータ(最新100値を保持)

setup関数

void setup() { 
  // シリアル通信を初期化し、通信速度を115200bpsに設定する
  // これにより、シリアルモニタにデバッグ情報を出力できるようになる
  Serial.begin(115200);

  // I2C通信を初期化する。SDAピンをGPIO 4、SCLピンをGPIO 5に設定
  // これは、ディスプレイやセンサーなど、I2Cデバイスとの通信に使用される
  Wire.begin(4, 5);

  // OLEDディスプレイの初期化を行う。ディスプレイが正しく初期化されたかをチェックする
  // ディスプレイのI2Cアドレスは0x3Cに設定されている
  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    // 初期化に失敗した場合、シリアルモニタにエラーメッセージを表示し、無限ループに入る
    Serial.println(F("SSD1306 allocation failed"));
    for(;;);
  } else {
    // 初期化に成功した場合、シリアルモニタに成功メッセージを表示する
    Serial.println(F("SSD1306 initialized successfully"));
  }

  // ディスプレイに何かを表示するための初期化の一環として、ディスプレイのバッファを更新
  display.display();
  // 初期メッセージが見えるようにするため、2秒間待機
  delay(2000);
  // ディスプレイの内容をクリア(画面を消去)
  display.clearDisplay();

  // ディスプレイの表示方向を180度回転させる(上下逆さまに表示)
  display.setRotation(2);

  // Wi-Fi接続を開始する。SSIDとパスワードを使用して接続を試みる
  WiFi.begin(ssid, password);
  // Wi-Fiに接続されるまでループしながら待機する
  while (WiFi.status() != WL_CONNECTED) {
    // 接続が確立されるまで1秒ごとに待機し、シリアルモニタに接続中のメッセージを表示
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  // Wi-Fiに接続が確立された場合、シリアルモニタに接続完了メッセージを表示
  Serial.println("Connected to WiFi");

  // NTPサーバーから現在時刻を取得するための設定
  // 9時間のオフセットを指定(日本標準時)し、タイムサーバーを設定する
  configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  // ロータリーエンコーダーのCLKピンを入力モードに設定
  pinMode(ENCODER_CLK, INPUT);
  // ロータリーエンコーダーのDTピンを入力モードに設定
  pinMode(ENCODER_DT, INPUT);
  // ロータリーエンコーダーのスイッチピンをプルアップ抵抗付きの入力モードに設定
  pinMode(ENCODER_SW, INPUT_PULLUP);

  // ロータリーエンコーダーのCLKピンの状態変化に応じて、updateRotary関数を割り込みハンドラとして登録
  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), updateRotary, CHANGE);
  // ロータリーエンコーダーのDTピンの状態変化に応じて、updateRotary関数を割り込みハンドラとして登録
  attachInterrupt(digitalPinToInterrupt(ENCODER_DT), updateRotary, CHANGE);

  // 天気データを取得する関数を呼び出す
  fetchWeatherData();
  // データ取得が完了するまで2秒待機
  delay(2000);
  // 日経平均株価データを取得する関数を呼び出す
  fetchStockData(nikkeiURL, nikkeiPrices);
  // データ取得が完了するまで2秒待機
  delay(2000);
  // ダウ平均株価データを取得する関数を呼び出す
  fetchStockData(dowURL, dowPrices);
  // データ取得が完了するまで2秒待機
  delay(2000);
  // 為替データ(USD/JPY)を取得する関数を呼び出す
  fetchUsdJpyData();
}

setup関数は、デバイスやセンサーの初期化、Wi-Fiへの接続、NTPサーバーからの時刻取得、ロータリーエンコーダーの割り込み設定、そして各種データの初期取得を行っています。

loop関数

void loop() {
  // 時間の更新をチェック
  // 前回の更新から設定したインターバル時間が経過したかを確認
  if (millis() - lastUpdateTime >= updateInterval) {
    // インターバル時間が経過していたら、現在の時刻を更新
    updateTime();
  }

  // 天気データの更新をチェック
  // 前回の天気データ更新から設定したインターバル時間が経過したか、またはデータが未準備かを確認
  if (millis() - lastWeatherUpdateTime >= weatherUpdateInterval || !weatherDataReady) {
    // インターバル時間が経過していたかデータが未準備なら、天気データを取得
    fetchWeatherData();
    // 更新した時刻を記録
    lastWeatherUpdateTime = millis();
  }

  // 市場が開いている場合に株価データの更新をチェック
  // 市場が開いているかを確認
  if (isMarketOpen()) {
    // 前回の更新から1分が経過したかを確認
    if (millis() - lastUpdateTime >= 60 * 1000) { // 1分に1回
      // 日経平均株価データを取得
      fetchStockData(nikkeiURL, nikkeiPrices);
      // データ取得の間に少し待機(2秒間)
      delay(2000); 
      // ダウ平均株価データを取得
      fetchStockData(dowURL, dowPrices);
      // 更新した時刻を記録
      lastUpdateTime = millis();
    }
  }

  // 為替データの更新は常に行う
  // 前回の為替データ更新から設定したインターバル時間(5分)が経過したかを確認
  if (millis() - lastUsdJpyUpdateTime >= usdJpyUpdateInterval) { // 5分に1回
    // インターバル時間が経過していたら、為替データを取得
    fetchUsdJpyData();
    // 更新した時刻を記録
    lastUsdJpyUpdateTime = millis();
  }

  // ロータリーエンコーダのスイッチ状態をチェック
  // ロータリーエンコーダのスイッチの現在の状態を読み取る
  static bool lastSwitchState = HIGH;
  bool currentSwitchState = digitalRead(ENCODER_SW);

  // スイッチが押された瞬間(前回の状態がHIGHで、現在の状態がLOW)を検出
  if (lastSwitchState == HIGH && currentSwitchState == LOW) {
    // 最後にボタンが押されてから一定時間が経過しているかを確認(デバウンス処理)
    if (millis() - lastButtonPress > debounceDelay) {
      // 表示モードを変更
      // 表示モードを次に進め、5つのモードを循環させる
      displayMode = (displayMode + 1) % 5;
      // ボタンが押された時刻を記録
      lastButtonPress = millis();
    }
  }
  // スイッチの状態を次回のループのために記録
  lastSwitchState = currentSwitchState;

  // 現在の表示モードに基づいて表示を更新
  switch (displayMode) {
    case 0:
      // 時間表示モードなら、現在の時刻を表示
      showCurrentTime();
      break;
    case 1:
      // 天気情報表示モードなら、天気情報を表示
      showWeatherInfo();
      break;
    case 2:
      // 日経平均株価表示モードなら、日経平均株価のグラフを表示
      stockName = "Nikkei 225";
      showStockInfo(nikkeiPrices);
      break;
    case 3:
      // ダウ平均株価表示モードなら、ダウ平均株価のグラフを表示
      stockName = "Dow Jones";
      showStockInfo(dowPrices);
      break;
    case 4:
      // 為替レート(USD/JPY)表示モードなら、為替レートのグラフを表示
      showUsdJpyInfo();
      break;
  }

  // ループの終了時に小さな遅延を追加して安定性を向上
  // これにより、CPU使用率を下げ、過度な処理負荷を防ぐ
  delay(10);
}

主にデータの更新を定期的にチェックし、必要に応じてデータを取得して表示内容を更新しています。また、ロータリーエンコーダーのスイッチを監視し、表示モードを切り替える処理も行っています。

updateRotary関数

void IRAM_ATTR updateRotary() { // 修正: IRAM_ATTR に変更 - 関数をIRAM(Instruction RAM)に配置することで、割り込みハンドラとして高速に実行されるようにする

  // ロータリーエンコーダーのCLKピンの状態(MSB: Most Significant Bit)を読み取る
  int MSB = digitalRead(ENCODER_CLK);
  
  // ロータリーエンコーダーのDTピンの状態(LSB: Least Significant Bit)を読み取る
  int LSB = digitalRead(ENCODER_DT);

  // 現在のエンコーダーの状態をエンコードする
  // MSBが2ビット目、LSBが1ビット目になるようにシフト演算と論理ORを使って2ビットの値を生成
  int encoded = (MSB << 1) | LSB;

  // 前回のエンコーダー状態(lastEncoded)をシフトし、現在のエンコード値を結合
  // これにより、4ビットの値が生成される。これがエンコーダーの現在と過去の状態の変化を表す
  int sum = (lastEncoded << 2) | encoded;

  // sumの値に基づいてエンコーダーが時計回りに回転した場合、rotaryValueを増加
  // エンコーダーの特定の状態遷移に対応する4ビットのパターン(1101、0100、0010、1011)をチェック
  if (sum == 0b1101 || sum == 0b0100 || sum == 0b0010 || sum == 0b1011) rotaryValue++;

  // sumの値に基づいてエンコーダーが反時計回りに回転した場合、rotaryValueを減少
  // エンコーダーの特定の状態遷移に対応する別の4ビットのパターン(1110、0111、0001、1000)をチェック
  if (sum == 0b1110 || sum == 0b0111 || sum == 0b0001 || sum == 0b1000) rotaryValue--;

  // 現在のエンコード状態を記憶
  // これにより、次回の呼び出し時に前回の状態として使用される
  lastEncoded = encoded;

  // rotaryValueが正(時計回り)の場合、scaleFactorを増加させる
  if (rotaryValue > 0) {
    scaleFactor += 0.1; // scaleFactorを0.1増加
    
    // scaleFactorが2.0を超えた場合、2.0に制限する
    if (scaleFactor > 2.0) scaleFactor = 2.0;
  } 
  // rotaryValueが負(反時計回り)の場合、scaleFactorを減少させる
  else if (rotaryValue < 0) {
    scaleFactor -= 0.1; // scaleFactorを0.1減少
    
    // scaleFactorが0.5未満になった場合、0.5に制限する
    if (scaleFactor < 0.5) scaleFactor = 0.5;
  }

  // rotaryValueをリセットして次回の回転に備える
  // これにより、スケールファクターの変更が一度の回転あたり1ステップだけ行われる
  rotaryValue = 0;
}

KY040の入力を即座に実行する必要があるため、IRAM_ATTRを用います。

IRAMとは、Instruction RAMの略称で、CPUに最も近いメモリのことである。

CPUに近いのでメモリが制限されてしまいますが、割り込みハンドラとして使うことができます。
つまり、ループ内に配置するよりも高速に、かつ確実に実行することができます。

showCurrentTime関数

showCurrentTime関数では、現在の時間を表示しています。

void showCurrentTime() {
  // 現在の時刻を取得するためのtime_t型変数を宣言し、現在の時刻を取得
  time_t now = time(nullptr);
  
  // 現在の時刻をtm構造体に変換し、そのポインタを取得
  struct tm* timeinfo = localtime(&now);

  // 時刻を文字列として格納するためのバッファを宣言(50文字分)
  char timeStringBuff[50];
  // 時刻をフォーマットし、バッファに格納("%H:%M:%S"の形式、例: 14:23:45)
  strftime(timeStringBuff, sizeof(timeStringBuff), "%H:%M:%S", timeinfo);

  // 日付を文字列として格納するためのバッファを宣言(50文字分)
  char dateStringBuff[50];
  // 日付をフォーマットし、バッファに格納("%Y-%m-%d (%a)"の形式、例: 2024-08-15 (Thu))
  strftime(dateStringBuff, sizeof(dateStringBuff), "%Y-%m-%d (%a)", timeinfo);

  // 曜日を示す文字列の配列を定義
  const char* daysOfWeek[7] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
  // 現在の曜日を取得し、そのインデックスを変数に格納(0: 日曜日、1: 月曜日...)
  int currentDay = timeinfo->tm_wday;

  // ディスプレイをクリア(画面を消去)
  display.clearDisplay();
  // テキストの色を白に設定(デフォルトで背景は黒)
  display.setTextColor(WHITE);

  // 時刻を表示するためのテキストサイズを2に設定
  display.setTextSize(2);
  // 時刻を表示するための位置を計算するために、テキストの幅と高さを取得
  int16_t x1, y1;
  uint16_t w, h;
  display.getTextBounds(timeStringBuff, 0, 0, &x1, &y1, &w, &h);
  // 時刻をディスプレイの中央に配置するようにカーソル位置を設定
  display.setCursor((SCREEN_WIDTH - w) / 2, 0);
  // 時刻をディスプレイに描画
  display.println(timeStringBuff);

  // 日付を表示するためのテキストサイズを1に設定
  display.setTextSize(1);
  // 日付を表示するための位置を計算するために、テキストの幅と高さを取得
  display.getTextBounds(dateStringBuff, 0, 0, &x1, &y1, &w, &h);
  // 日付をディスプレイの中央に配置するようにカーソル位置を設定
  display.setCursor((SCREEN_WIDTH - w) / 2, 32);
  // 日付をディスプレイに描画
  display.println(dateStringBuff);

  // 曜日表示用のボックスの幅と高さを設定
  int boxWidth = 16;
  int boxHeight = 10;
  // ボックスのY座標を設定(画面下端からボックスの高さと2ピクセル上)
  int boxY = SCREEN_HEIGHT - boxHeight - 2;

  // 7つの曜日のボックスを順に描画
  for (int i = 0; i < 7; i++) {
    // ボックスのX座標を計算(各ボックスは横に並べられる)
    int boxX = i * (boxWidth + 2);
    // 現在の曜日のボックスは塗りつぶし、それ以外は枠だけ描画
    if (i == currentDay) {
      // 現在の曜日に対応するボックスを白で塗りつぶす
      display.fillRect(boxX, boxY, boxWidth, boxHeight, WHITE);
      // 塗りつぶしたボックス内のテキスト色を黒に設定
      display.setTextColor(BLACK);
    } else {
      // その他の曜日は枠のみ描画
      display.drawRect(boxX, boxY, boxWidth, boxHeight, WHITE);
      // 枠内のテキスト色を白に設定
      display.setTextColor(WHITE);
    }
    // 曜日をボックス内に描画
    display.setCursor(boxX + 2, boxY + 2);
    display.print(daysOfWeek[i]);
  }

  // ディスプレイのバッファを更新し、内容を表示
  display.display();
}

現在の時刻と日付、そして曜日をOLEDディスプレイに表示します。時刻と日付は中央に配置され、現在の曜日は強調されて表示されるようになっています。

fetchWeatherData関数

この関数では、天気データを取得しています。シリアルモニタに取得したデータを表示しています。

void fetchWeatherData() {
  // WiFiClientオブジェクトを作成し、HTTPリクエストのためのクライアントを準備
  WiFiClient client;
  // HTTPサーバーのポート番号を定義(通常HTTPは80番ポートを使用)
  const int httpPort = 80;
  
  // 天気サーバーへの接続を試みる
  if (!client.connect(weatherServer, httpPort)) {
    // 接続に失敗した場合、エラーメッセージをシリアルモニタに出力し、フラグを設定して終了
    Serial.println("Connection to weather server failed");
    weatherDataReady = false;
    return;
  }

  // APIリクエストURLを作成。クエリ文字列に都市名、APIキー、メトリック単位の指定を含める
  String url = "/data/2.5/weather?q=" + String(city) + "&appid=" + String(apiKey) + "&units=metric";
  // リクエストURLをシリアルモニタに出力
  Serial.println("Requesting weather URL: " + url);
  
  // HTTP GETリクエストをサーバーに送信
  client.print(String("GET ") + url + " HTTP/1.1\r\n" +
               "Host: " + weatherServer + "\r\n" + 
               "Connection: close\r\n\r\n");

  // サーバーからのレスポンスを待つ
  while (client.available() == 0) {
    // サーバーが接続を維持しているか確認。切断された場合、エラーメッセージを出力して終了
    if (!client.connected()) {
      Serial.println("Weather server disconnected before response received.");
      client.stop();
      weatherDataReady = false;
      return;
    }
    // サーバーの応答を待つ間、短時間(100ms)の遅延を挟む
    delay(100);
  }

  // サーバーからのレスポンスを格納するための文字列を初期化
  String response = "";
  // レスポンスがある限りデータを読み取り、レスポンス文字列に追加
  while (client.available()) {
    String line = client.readStringUntil('\r');
    response += line;
  }

  // クライアント接続を終了
  client.stop();
  // 受信したレスポンスをシリアルモニタに出力
  Serial.println("Weather server response: " + response);

  // レスポンスのJSONデータの開始位置を検索
  int jsonStart = response.indexOf('{');
  if (jsonStart == -1) {
    // JSONデータが見つからない場合、エラーメッセージを出力して終了
    Serial.println("Invalid weather response - no JSON found");
    weatherDataReady = false;
    return;
  }
  // JSONデータを切り出してjsonResponseに格納
  String jsonResponse = response.substring(jsonStart);

  // 4096バイトのメモリを使用してDynamicJsonDocumentを作成(JSONデータのパース用)
  DynamicJsonDocument doc(4096);
  // JSONデータをdocにパースする。エラーが発生した場合、エラーメッセージを出力して終了
  DeserializationError error = deserializeJson(doc, jsonResponse);

  if (error) {
    Serial.print(F("Weather deserializeJson() failed: "));
    Serial.println(error.f_str());
    weatherDataReady = false;
    return;
  }

  // JSON内の "weather" キーをチェックし、存在する場合は天気の主な状態と詳細を取得
  if (doc.containsKey("weather")) {
    weatherMain = doc["weather"][0]["main"].as<String>();
    weatherDescription = doc["weather"][0]["description"].as<String>();
    Serial.print("Weather: ");
    Serial.println(weatherMain);
    Serial.print("Description: ");
    Serial.println(weatherDescription);
  }
  
  // JSON内の "main" キーをチェックし、存在する場合は温度、湿度、気圧を取得
  if (doc.containsKey("main")) {
    temperature = doc["main"]["temp"].as<float>();
    humidity = doc["main"]["humidity"].as<int>();
    pressure = doc["main"]["pressure"].as<int>();
    Serial.print("Temperature: ");
    Serial.println(temperature);
    Serial.print("Humidity: ");
    Serial.println(humidity);
    Serial.print("Pressure: ");
    Serial.println(pressure);
  }

  // JSON内の "wind" キーをチェックし、存在する場合は風速を取得
  if (doc.containsKey("wind")) {
    windSpeed = doc["wind"]["speed"].as<int>();
    Serial.print("Wind Speed: ");
    Serial.println(windSpeed);
  }

  // JSON内の "clouds" キーをチェックし、存在する場合は曇り度を取得
  if (doc.containsKey("clouds")) {
    cloudiness = doc["clouds"]["all"].as<int>();
    Serial.print("Cloudiness: ");
    Serial.println(cloudiness);
  }

  // 天気データの取得と解析が完了したことを示すフラグを立てる
  weatherDataReady = true;
}

この関数は、Wi-Fi接続を使用してOpenWeatherMap APIから天気データを取得し、そのデータを解析して温度、湿度、気圧、風速、曇り度などの情報を抽出する役割があります。

extractPrice

float extractPrice(WiFiClient& client, bool isOpen) {
    // foundPriceは価格が見つかったかどうかを追跡するためのフラグ
    bool foundPrice = false;
    // データを一時的に保持するための文字列バッファ
    String buffer = "";
    // 抽出するキーを設定。isOpenがtrueなら"regularMarketPrice"、falseなら"previousClose"
    String keyToFind = isOpen ? "\"regularMarketPrice\"" : "\"previousClose\"";
    // 抽出した価格を格納するための変数。初期値は0.0
    float price = 0.0;

    // クライアントが接続されているか、データがまだ受信されている場合はループ
    while (client.connected() || client.available()) {
        // データが利用可能である場合
        if (client.available()) {
            // 1バイトのデータを読み取る
            char c = client.read();
            // 読み取ったデータをバッファに追加
            buffer += c;

            // バッファの末尾が指定されたキーで終わっているか確認
            if (buffer.endsWith(keyToFind)) {
                // キーの後に続くコロンをスキップ
                client.read(); // Skip ':'
                // 次のカンマまでの文字列を読み取り、それを価格として変換
                String valueString = client.readStringUntil(',');
                price = valueString.toFloat();
                // 価格が見つかったことを示すフラグを立てる
                foundPrice = true;
                // 価格が見つかったのでループを抜ける
                break;
            }

            // バッファが1024文字を超える場合、古いデータを削除してバッファをクリア
            if (buffer.length() > 1024) { 
                buffer = buffer.substring(512);
            }
        }
    }

    // 価格が見つかった場合はその価格を、見つからなかった場合は0.0を返す
    return foundPrice ? price : 0.0;
}

この関数は、この後解説するfetchStockData関数や、fetchUsdJpyData関数に関係があります。最初のAPIの定義のところで話した通り、APIのURLのインターバルを1mに設定することで問題が発生しました。1mのデータは細かいので、120KBものデータがありました。ESPにはそんなデータ入りません。そこで、株価などのjsonを覗くとregularMarketPricePreviousCloseという要素があり、市場が開いているときはregularMarketPriceを発見し、それ以外のデータは無視します。また、市場が閉まっているときはPreviousCloseを発見してその値を表示します。

fetchStockData関数

void fetchStockData(String apiUrl, float* prices) {
    // WiFiClientSecureオブジェクトを作成し、HTTPS通信を行うためのクライアントを準備
    WiFiClientSecure client;
    // サーバー証明書の検証を無効化。これにより、セキュアな接続が確立されるが、証明書の検証は行われない
    client.setInsecure();

    // HTTPClientオブジェクトを作成し、HTTPリクエストを行う準備をする
    HTTPClient http;

    // シリアルモニタに接続試行メッセージを出力
    Serial.println("Connecting to Yahoo Finance...");

    // 指定されたAPI URLに対してHTTP接続を確立する
    if (http.begin(client, apiUrl)) {
        // GETリクエストを送信し、HTTPステータスコードを取得
        int httpCode = http.GET();

        // HTTPリクエストが成功したかを確認
        if (httpCode > 0) {
            // リクエストが成功した場合、ステータスコードをシリアルモニタに表示
            Serial.println("HTTP GET succeeded, code: " + String(httpCode));

            // 株価を抽出するためにextractPrice関数を呼び出し、取得した株価をlastPriceに格納
            float lastPrice = extractPrice(client, isMarketOpen());

            // 株価が有効な値であるかを確認(0.0は無効な値として扱われる)
            if (lastPrice != 0.0) {
                // 取得した株価をシリアルモニタに表示
                Serial.println("Last price: " + String(lastPrice));

                // prices配列の各要素を1つずつ左にシフト(古いデータを捨て、新しいデータを受け入れる準備)
                for (int i = 0; i < 99; i++) {
                    prices[i] = prices[i + 1];
                }
                // 新しく取得した株価を配列の最後に追加
                prices[99] = lastPrice;
            } else {
                // 株価が抽出できなかった場合、エラーメッセージをシリアルモニタに表示
                Serial.println("Failed to extract price.");
            }
        } else {
            // HTTPリクエストが失敗した場合、エラーメッセージとステータスコードをシリアルモニタに表示
            Serial.println("Failed to fetch stock data, HTTP code: " + String(httpCode));
        }

        // HTTP接続を終了
        http.end();
    } else {
        // サーバーへの接続に失敗した場合、エラーメッセージをシリアルモニタに表示
        Serial.println("Connection to stock server failed");
    }
}

この関数では、株価のデータを取得しています。株価のグラフを今後表示したいので、値を取得するたびにシフト動作を行って、配列を右にずらしています。

fetchUsdJpyData関数

void fetchUsdJpyData() {
  // WiFiClientSecureオブジェクトを作成し、HTTPS通信を行うためのクライアントを準備
  WiFiClientSecure client;
  // サーバー証明書の検証を無効化。これにより、セキュアな接続が確立されるが、証明書の検証は行われない
  client.setInsecure();

  // HTTPClientオブジェクトを作成し、HTTPリクエストを行う準備をする
  HTTPClient http;

  // シリアルモニタに接続試行メッセージを出力
  Serial.println("Connecting to Yahoo Finance for USD/JPY...");

  // 指定されたAPI URLに対してHTTP接続を確立する
  if (http.begin(client, usdJpyURL)) {
    // GETリクエストを送信し、HTTPステータスコードを取得
    int httpCode = http.GET();

    // HTTPリクエストが成功したかを確認
    if (httpCode > 0) {
      // リクエストが成功した場合、ステータスコードをシリアルモニタに表示
      Serial.println("USD/JPY HTTP GET succeeded, code: " + String(httpCode));

      // 為替レートを抽出するためにextractPrice関数を呼び出し、取得した価格をusdJpyLastPriceに格納
      float usdJpyLastPrice = extractPrice(client, true);

      // 為替レートが有効な値であるかを確認(0.0は無効な値として扱われる)
      if (usdJpyLastPrice != 0.0) {
        // 取得した為替レートをシリアルモニタに表示
        Serial.print("USD/JPY: ");
        Serial.println(usdJpyLastPrice);

        // usdJpyPrices配列の各要素を1つずつ左にシフト(古いデータを捨て、新しいデータを受け入れる準備)
        for (int i = 0; i < 99; i++) {
          usdJpyPrices[i] = usdJpyPrices[i + 1];
        }
        // 新しく取得した為替レートを配列の最後に追加
        usdJpyPrices[99] = usdJpyLastPrice;

        // usdJpyPrice変数に最新の為替レートを格納
        usdJpyPrice = usdJpyLastPrice;
      } else {
        // 為替レートが抽出できなかった場合、エラーメッセージをシリアルモニタに表示
        Serial.println("Failed to extract USD/JPY price.");
      }
    } else {
      // HTTPリクエストが失敗した場合、エラーメッセージとステータスコードをシリアルモニタに表示
      Serial.println("Failed to fetch USD/JPY data, HTTP code: " + String(httpCode));
    }

    // HTTP接続を終了
    http.end();
  } else {
    // サーバーへの接続に失敗した場合、エラーメッセージをシリアルモニタに表示
    Serial.println("Connection to USD/JPY server failed");
  }
}

先ほどの株式データのfetch関数とほぼ一緒です。

showStockInfo関数、showUsdJpyInfo関数およびshowWeatherInfo関数

showStockData関数
void showStockInfo(float* prices) {
  // ディスプレイをクリアして、画面を消去
  display.clearDisplay();
  // テキストの色を白に設定
  display.setTextColor(WHITE);
  // テキストのサイズを1に設定(デフォルトサイズ)
  display.setTextSize(1);

  // 株価のラベル(銘柄名)を画面の最上部に表示
  display.setCursor(0, 0);
  display.print(stockName);

  // 最新の株価を画面に表示
  display.setTextSize(2);  // テキストサイズを2に設定して強調表示
  display.setCursor(0, 10);  // 表示位置を設定(Y座標は10ピクセル)
  display.print(prices[99]);  // 配列の最後の値(最新の値)を表示

  // 市場の状態(オープン/クローズ)を表示
  display.setTextSize(1);  // テキストサイズを1に戻す
  if (isMarketOpen()) {  // 市場がオープンしている場合
    display.setCursor(SCREEN_WIDTH - 30, 10);  // 表示位置を右端に設定
    display.print("Open");
  } else {  // 市場がクローズしている場合
    display.setCursor(SCREEN_WIDTH - 30, 10);  // 表示位置を右端に設定
    display.print("Close");
  }

  // グラフを表示するための枠を描画
  int graphTop = 26;  // グラフの上端のY座標
  int graphBottom = SCREEN_HEIGHT - 16;  // グラフの下端のY座標(画面の下端から16ピクセル上)
  int graphHeight = graphBottom - graphTop;  // グラフの高さを計算
  display.drawRect(0, graphTop, SCREEN_WIDTH, graphHeight, WHITE);  // グラフの枠を描画

  // Y軸の最小値と最大値を求める
  float minPrice = prices[0];  // 最小値を配列の最初の値で初期化
  float maxPrice = prices[0];  // 最大値を配列の最初の値で初期化
  for (int i = 1; i < 100; i++) {  // 配列の全ての値をチェック
    if (prices[i] < minPrice) minPrice = prices[i];  // より小さい値があれば更新
    if (prices[i] > maxPrice) maxPrice = prices[i];  // より大きい値があれば更新
  }

  // Y軸の範囲を計算
  float range = (maxPrice - minPrice) * scaleFactor;  // 最大値と最小値の差にスケールファクターを掛ける
  if (range == 0) range = 1;  // 万一rangeが0の場合、1に設定してゼロ除算を防ぐ
  float scale = (graphHeight - 2) / range;  // グラフの高さをY軸の範囲に合わせてスケール計算

  // 株価データをグラフとして描画
  for (int i = 0; i < 99; i++) {
    int x0 = (i * (SCREEN_WIDTH - 2)) / 99;  // X軸の開始位置を計算(左端から順に)
    int y0 = graphBottom - scale * (prices[i] - minPrice);  // Y軸の開始位置を計算(スケールに合わせて位置調整)
    int x1 = ((i + 1) * (SCREEN_WIDTH - 2)) / 99;  // X軸の次の位置を計算
    int y1 = graphBottom - scale * (prices[i + 1] - minPrice);  // Y軸の次の位置を計算
    display.drawLine(x0, y0, x1, y1, WHITE);  // 2点間を結ぶ直線を描画
  }

  // 最小値と最大値をグラフの下に表示
  display.setTextSize(1);  // テキストサイズを1に設定
  display.setCursor(0, graphBottom + 2);  // 最小値の表示位置を設定
  display.print("Min: ");
  display.print(minPrice);  // 最小値を表示
  display.setCursor(SCREEN_WIDTH - 50, graphBottom + 2);  // 最大値の表示位置を設定
  display.print("Max: ");
  display.print(maxPrice);  // 最大値を表示

  // 表示内容をディスプレイに反映
  display.display();
}

showUsdJpyInfo関数
void showUsdJpyInfo() {
  // ディスプレイをクリアして、画面を消去
  display.clearDisplay();
  // テキストの色を白に設定
  display.setTextColor(WHITE);
  // テキストのサイズを1に設定(デフォルトサイズ)
  display.setTextSize(1);

  // ラベル(USD/JPY)を画面の最上部に表示
  display.setCursor(0, 0);
  display.print("USD/JPY");

  // 最新の為替レートを画面に表示
  display.setTextSize(2);  // テキストサイズを2に設定して強調表示
  display.setCursor(0, 10);  // 表示位置を設定(Y座標は10ピクセル)
  display.print(usdJpyPrices[99]);  // 配列の最後の値(最新の値)を表示

  // グラフを表示するための枠を描画
  int graphTop = 26;  // グラフの上端のY座標
  int graphBottom = SCREEN_HEIGHT - 16;  // グラフの下端のY座標(画面の下端から16ピクセル上)
  int graphHeight = graphBottom - graphTop;  // グラフの高さを計算
  display.drawRect(0, graphTop, SCREEN_WIDTH, graphHeight, WHITE);  // グラフの枠を描画

  // Y軸の最小値と最大値を求める
  float minPrice = usdJpyPrices[0];  // 最小値を配列の最初の値で初期化
  float maxPrice = usdJpyPrices[0];  // 最大値を配列の最初の値で初期化
  for (int i = 1; i < 100; i++) {  // 配列の全ての値をチェック
    if (usdJpyPrices[i] < minPrice) minPrice = usdJpyPrices[i];  // より小さい値があれば更新
    if (usdJpyPrices[i] > maxPrice) maxPrice = usdJpyPrices[i];  // より大きい値があれば更新
  }

  // Y軸の範囲を計算
  float range = (maxPrice - minPrice) * scaleFactor;  // 最大値と最小値の差にスケールファクターを掛ける
  if (range == 0) range = 1;  // 万一rangeが0の場合、1に設定してゼロ除算を防ぐ
  float scale = (graphHeight - 2) / range;  // グラフの高さをY軸の範囲に合わせてスケール計算

  // 為替レートデータをグラフとして描画
  for (int i = 0; i < 99; i++) {
    int x0 = (i * (SCREEN_WIDTH - 2)) / 99;  // X軸の開始位置を計算(左端から順に)
    int y0 = graphBottom - scale * (usdJpyPrices[i] - minPrice);  // Y軸の開始位置を計算(スケールに合わせて位置調整)
    int x1 = ((i + 1) * (SCREEN_WIDTH - 2)) / 99;  // X軸の次の位置を計算
    int y1 = graphBottom - scale * (usdJpyPrices[i + 1] - minPrice);  // Y軸の次の位置を計算
    display.drawLine(x0, y0, x1, y1, WHITE);  // 2点間を結ぶ直線を描画
  }

  // 最小値と最大値をグラフの下に表示
  display.setTextSize(1);  // テキストサイズを1に設定
  display.setCursor(0, graphBottom + 2);  // 最小値の表示位置を設定
  display.print("Min: ");
  display.print(minPrice);  // 最小値を表示
  display.setCursor(SCREEN_WIDTH - 50, graphBottom + 2);  // 最大値の表示位置を設定
  display.print("Max: ");
  display.print(maxPrice);  // 最大値を表示

  // 表示内容をディスプレイに反映
  display.display();
}

showWeatherInfo関数
void showWeatherInfo() {
  // ディスプレイをクリアして、画面を消去
  display.clearDisplay();
  // テキストの色を白に設定
  display.setTextColor(WHITE);
  // テキストのサイズを1に設定(デフォルトサイズ)
  display.setTextSize(1);

  // 天気データが準備できているかどうかをチェック
  if (!weatherDataReady) {
    // データがまだ準備できていない場合、"Now Loading..." と表示
    display.setCursor((SCREEN_WIDTH - 66) / 2, (SCREEN_HEIGHT - 8) / 2);  // 画面の中央に配置
    display.print("Now Loading...");
  } else {
    // 天気データが準備できている場合、情報をディスプレイに表示
    display.setCursor(0, 0);
    display.print("Weather: " + weatherMain);  // 天気の主な状態を表示

    display.setCursor(0, 10);
    display.print("Desc: " + weatherDescription);  // 天気の詳細を表示

    display.setCursor(0, 20);
    display.print("Temp: " + String(temperature) + " C");  // 温度を表示

    display.setCursor(0, 30);
    display.print("Humidity: " + String(humidity) + " %");  // 湿度を表示

    display.setCursor(0, 40);
    display.print("Pressure: " + String(pressure) + " hPa");  // 気圧を表示

    display.setCursor(0, 50);
    display.print("Wind: " + String(windSpeed) + " m/s");  // 風速を表示

    display.setCursor(64, 50);
    display.print("Clouds: " + String(cloudiness) + " %");  // 曇り度を表示
  }
  
  // 表示内容をディスプレイに反映
  display.display();
}

isMarketOpen関数

この関数は、市場が閉じているときに値を要求しても値の変化は望めないため、市場が閉じているか開いているかを返す関数です。

bool isMarketOpen() {
  // 現在の時刻を取得し、time_t 型の変数に格納
  time_t now = time(nullptr);
  // 取得した時刻をローカル時刻に変換し、tm 構造体に格納
  struct tm* timeinfo = localtime(&now);

  // 現在の時刻のうち、時間と分、曜日をそれぞれ取得
  int hour = timeinfo->tm_hour;
  int minute = timeinfo->tm_min;
  int wday = timeinfo->tm_wday;

  // 銘柄が日経225であるかどうかをチェック
  if (stockName == "Nikkei 225") {
    // 曜日が日曜日でも土曜日でもない場合(平日)に市場の状態をチェック
    if (wday != 0 && wday != 6) {
      // 午前9時から11時まで、または11時30分まで市場が開いている
      if ((hour >= 9 && hour < 11) || (hour == 11 && minute < 30)) return true;
      // 午後12時から15時まで市場が開いている
      if ((hour >= 12 && hour < 15)) return true;
    }
    // それ以外の時間は市場が閉まっている
    return false;
  }

  // 銘柄がダウジョーンズであるかどうかをチェック
  if (stockName == "Dow Jones") {
    // 曜日が日曜日でも土曜日でもない場合(平日)に市場の状態をチェック
    if (wday != 0 && wday != 6) {
      // 午後11時から翌日の午前6時まで市場が開いている
      if ((hour >= 23) || (hour < 6)) return true;
    }
    // それ以外の時間は市場が閉まっている
    return false;
  }

  // デフォルトで市場が開いていると仮定
  return true;
}

updateTime関数

この関数では、1時間に一回正確な時刻を取得するための関数です。

void updateTime() {
  // NTPサーバーを使用して現在の時刻を設定(日本標準時で9時間のオフセットを適用)
  configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  // 時刻が正しく設定されるまで待機
  while(!time(nullptr)) {
    // 時刻の取得を試みていることをシリアルモニタに表示
    Serial.println("Waiting for time");
    // 1秒間待機
    delay(1000);
  }

  // 最後に時刻が更新された時間をミリ秒単位で記録
  lastUpdateTime = millis();
}

終わりに

今回こういったものを作成しましたが、かなりコードが長くなりなおかつ見にくい部分があるかもしれませんが、ご了承ください。わからない点があればコメントしていただければお答えいたします。お気づきの方もいらっしゃると思いますが、後半は失速気味でした。なので、解説が足りないところがあるかもしれません(;^_^A

現在はこれで終了していますが、今後はハウジングなどを作成してPC横に設置したいと思います。完成したときにはQiitaにて、公開したと思います。

追記

部屋の気温も表示できるようにBMP280センサーを購入しました。購入したものがなんと、足がついておらず7年ぶりぐらいに、はんだを出して適当につけてみましたが。画像のように最低の出来で諦めました。
IMG_4823.jpg

ソースコード

#include <ESP8266WiFi.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
#include <time.h>
#include <ESP8266HTTPClient.h>

// Wi-Fi設定
const char* ssid = "Your_SSID";
const char* password = "Your_Pass";

// API設定
const char* weatherServer = "api.openweathermap.org";
const char* apiKey = "Your_API_Key"; // OpenWeatherMap APIキー
const char* city = "Osaka,jp"; // 都市名と国コード

const char* nikkeiURL = "https://query1.finance.yahoo.com/v8/finance/chart/%5EN225?interval=1m";
const char* dowURL = "https://query1.finance.yahoo.com/v8/finance/chart/%5EDJI?interval=1m";
const char* usdJpyURL = "https://query1.finance.yahoo.com/v8/finance/chart/USDJPY=X?interval=1m";

// OLEDディスプレイの設定
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

#define ENCODER_CLK 14  // CLKピン (D5)
#define ENCODER_DT 12   // DTピン (D6)
#define ENCODER_SW 13   // SWピン (D7)

unsigned long lastUpdateTime = 0;
const unsigned long updateInterval = 30 * 60 * 1000; // 30分 = 30 * 60 * 1000ミリ秒
unsigned long lastWeatherUpdateTime = 0;
const unsigned long weatherUpdateInterval = 60 * 60 * 1000; // 1時間 = 60 * 60 * 1000ミリ秒
unsigned long lastUsdJpyUpdateTime = 0;
const unsigned long usdJpyUpdateInterval = 5 * 60 * 1000; // 5分 = 5 * 60 * 1000ミリ秒
volatile int rotaryValue = 0;
volatile int lastEncoded = 0;
int displayMode = 0; // 0: 時間, 1: 天気, 2: 日経, 3: ダウ, 4: 円相場
unsigned long lastButtonPress = 0;
const unsigned long debounceDelay = 200; // デバウンス時間(ミリ秒)

float nikkeiPrices[100] = {30000};  // 初期値を30000で設定
float dowPrices[100] = {30000};     // 初期値を30000で設定
float usdJpyPrice = 0.0;
float scaleFactor = 1.0;      // グラフの拡大縮小のためのスケールファクター
String stockName;
String weatherMain = "N/A";
String weatherDescription = "N/A";
float temperature = 0.0;
int humidity = 0;
int pressure = 0;
int windSpeed = 0;
int cloudiness = 0;
bool weatherDataReady = false;
float usdJpyPrices[100] = {30000};  // 初期値を30000で設定

void IRAM_ATTR updateRotary() { // 修正: IRAM_ATTR に変更
  int MSB = digitalRead(ENCODER_CLK);
  int LSB = digitalRead(ENCODER_DT);

  int encoded = (MSB << 1) | LSB;
  int sum = (lastEncoded << 2) | encoded;

  if (sum == 0b1101 || sum == 0b0100 || sum == 0b0010 || sum == 0b1011) rotaryValue++;
  if (sum == 0b1110 || sum == 0b0111 || sum == 0b0001 || sum == 0b1000) rotaryValue--;

  lastEncoded = encoded;

  if (rotaryValue > 0) {
    scaleFactor += 0.1;
    if (scaleFactor > 2.0) scaleFactor = 2.0;
  } else if (rotaryValue < 0) {
    scaleFactor -= 0.1;
    if (scaleFactor < 0.5) scaleFactor = 0.5;
  }
  rotaryValue = 0; // 値をリセット
}

void setup() {
  Serial.begin(115200);

  Wire.begin(4, 5);  // SDA=4, SCL=5

  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    Serial.println(F("SSD1306 allocation failed"));
    for(;;);
  } else {
    Serial.println(F("SSD1306 initialized successfully"));
  }

  display.display();
  delay(2000);
  display.clearDisplay();

  display.setRotation(2);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  Serial.println("Connected to WiFi");

  configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  pinMode(ENCODER_CLK, INPUT);
  pinMode(ENCODER_DT, INPUT);
  pinMode(ENCODER_SW, INPUT_PULLUP);

  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), updateRotary, CHANGE);
  attachInterrupt(digitalPinToInterrupt(ENCODER_DT), updateRotary, CHANGE);

  fetchWeatherData();
  delay(2000);
  fetchStockData(nikkeiURL, nikkeiPrices);
  delay(2000);
  fetchStockData(dowURL, dowPrices);
  delay(2000);
  fetchUsdJpyData();
}

void loop() {
  // 時間の更新をチェック
  if (millis() - lastUpdateTime >= updateInterval) {
    updateTime();
  }

  // 天気データの更新をチェック
  if (millis() - lastWeatherUpdateTime >= weatherUpdateInterval || !weatherDataReady) {
    fetchWeatherData();
    lastWeatherUpdateTime = millis();
  }

  // 市場が開いている場合に株価データの更新をチェック
  if (isMarketOpen()) {
    if (millis() - lastUpdateTime >= 60 * 1000) { // 1分に1回
      fetchStockData(nikkeiURL, nikkeiPrices);
      delay(2000); // 少し間を置く
      fetchStockData(dowURL, dowPrices);
      lastUpdateTime = millis();
    }
  }

  // 為替データの更新は常に行う
  if (millis() - lastUsdJpyUpdateTime >= usdJpyUpdateInterval) { // 5分に1回
    fetchUsdJpyData();
    lastUsdJpyUpdateTime = millis();
  }

  // ロータリーエンコーダのスイッチ状態をチェック
  static bool lastSwitchState = HIGH;
  bool currentSwitchState = digitalRead(ENCODER_SW);

  if (lastSwitchState == HIGH && currentSwitchState == LOW) {
    if (millis() - lastButtonPress > debounceDelay) {
      displayMode = (displayMode + 1) % 5;
      lastButtonPress = millis();
    }
  }
  lastSwitchState = currentSwitchState;

  // 現在の表示モードに基づいて表示を更新
  switch (displayMode) {
    case 0:
      showCurrentTime();
      break;
    case 1:
      showWeatherInfo();
      break;
    case 2:
      stockName = "Nikkei 225";
      showStockInfo(nikkeiPrices);
      break;
    case 3:
      stockName = "Dow Jones";
      showStockInfo(dowPrices);
      break;
    case 4:
      showUsdJpyInfo();
      break;
  }

  // ループの終了時に小さな遅延を追加して安定性を向上
  delay(10);
}

void showCurrentTime() {
  time_t now = time(nullptr);
  struct tm* timeinfo = localtime(&now);

  char timeStringBuff[50];
  strftime(timeStringBuff, sizeof(timeStringBuff), "%H:%M:%S", timeinfo);

  char dateStringBuff[50];
  strftime(dateStringBuff, sizeof(dateStringBuff), "%Y-%m-%d (%a)", timeinfo);

  const char* daysOfWeek[7] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
  int currentDay = timeinfo->tm_wday;

  display.clearDisplay();
  display.setTextColor(WHITE);

  display.setTextSize(2);
  int16_t x1, y1;
  uint16_t w, h;
  display.getTextBounds(timeStringBuff, 0, 0, &x1, &y1, &w, &h);
  display.setCursor((SCREEN_WIDTH - w) / 2, 0);
  display.println(timeStringBuff);

  display.setTextSize(1);
  display.getTextBounds(dateStringBuff, 0, 0, &x1, &y1, &w, &h);
  display.setCursor((SCREEN_WIDTH - w) / 2, 32);
  display.println(dateStringBuff);

  int boxWidth = 16;
  int boxHeight = 10;
  int boxY = SCREEN_HEIGHT - boxHeight - 2;

  for (int i = 0; i < 7; i++) {
    int boxX = i * (boxWidth + 2);
    if (i == currentDay) {
      display.fillRect(boxX, boxY, boxWidth, boxHeight, WHITE);
      display.setTextColor(BLACK);
    } else {
      display.drawRect(boxX, boxY, boxWidth, boxHeight, WHITE);
      display.setTextColor(WHITE);
    }
    display.setCursor(boxX + 2, boxY + 2);
    display.print(daysOfWeek[i]);
  }

  display.display();
}

void fetchWeatherData() {
  WiFiClient client;
  const int httpPort = 80;
  
  if (!client.connect(weatherServer, httpPort)) {
    Serial.println("Connection to weather server failed");
    weatherDataReady = false;
    return;
  }

  String url = "/data/2.5/weather?q=" + String(city) + "&appid=" + String(apiKey) + "&units=metric";
  Serial.println("Requesting weather URL: " + url);
  client.print(String("GET ") + url + " HTTP/1.1\r\n" +
               "Host: " + weatherServer + "\r\n" + 
               "Connection: close\r\n\r\n");

  while (client.available() == 0) {
    if (!client.connected()) {
      Serial.println("Weather server disconnected before response received.");
      client.stop();
      weatherDataReady = false;
      return;
    }
    delay(100);
  }

  String response = "";
  while (client.available()) {
    String line = client.readStringUntil('\r');
    response += line;
  }

  client.stop();
  Serial.println("Weather server response: " + response);

  int jsonStart = response.indexOf('{');
  if (jsonStart == -1) {
    Serial.println("Invalid weather response - no JSON found");
    weatherDataReady = false;
    return;
  }
  String jsonResponse = response.substring(jsonStart);

  DynamicJsonDocument doc(4096);
  DeserializationError error = deserializeJson(doc, jsonResponse);

  if (error) {
    Serial.print(F("Weather deserializeJson() failed: "));
    Serial.println(error.f_str());
    weatherDataReady = false;
    return;
  }

  if (doc.containsKey("weather")) {
    weatherMain = doc["weather"][0]["main"].as<String>();
    weatherDescription = doc["weather"][0]["description"].as<String>();
    Serial.print("Weather: ");
    Serial.println(weatherMain);
    Serial.print("Description: ");
    Serial.println(weatherDescription);
  }
  if (doc.containsKey("main")) {
    temperature = doc["main"]["temp"].as<float>();
    humidity = doc["main"]["humidity"].as<int>();
    pressure = doc["main"]["pressure"].as<int>();
    Serial.print("Temperature: ");
    Serial.println(temperature);
    Serial.print("Humidity: ");
    Serial.println(humidity);
    Serial.print("Pressure: ");
    Serial.println(pressure);
  }
  if (doc.containsKey("wind")) {
    windSpeed = doc["wind"]["speed"].as<int>();
    Serial.print("Wind Speed: ");
    Serial.println(windSpeed);
  }
  if (doc.containsKey("clouds")) {
    cloudiness = doc["clouds"]["all"].as<int>();
    Serial.print("Cloudiness: ");
    Serial.println(cloudiness);
  }

  weatherDataReady = true;
}

float extractPrice(WiFiClient& client, bool isOpen) {
    bool foundPrice = false;
    String buffer = "";
    String keyToFind = isOpen ? "\"regularMarketPrice\"" : "\"previousClose\"";
    float price = 0.0;

    while (client.connected() || client.available()) {
        if (client.available()) {
            char c = client.read();
            buffer += c;

            if (buffer.endsWith(keyToFind)) {
                client.read(); // Skip ':'
                String valueString = client.readStringUntil(',');
                price = valueString.toFloat();
                foundPrice = true;
                break;
            }

            if (buffer.length() > 1024) { // Clear buffer if it gets too large
                buffer = buffer.substring(512);
            }
        }
    }

    return foundPrice ? price : 0.0;
}

void fetchStockData(String apiUrl, float* prices) {
    WiFiClientSecure client;
    client.setInsecure();

    HTTPClient http;

    Serial.println("Connecting to Yahoo Finance...");

    if (http.begin(client, apiUrl)) {
        int httpCode = http.GET();

        if (httpCode > 0) {
            Serial.println("HTTP GET succeeded, code: " + String(httpCode));

            float lastPrice = extractPrice(client, isMarketOpen());

            if (lastPrice != 0.0) {
                Serial.println("Last price: " + String(lastPrice));

                // 配列を左にシフト
                for (int i = 0; i < 99; i++) {
                    prices[i] = prices[i + 1];
                }
                // 新しい値を配列の最後に追加
                prices[99] = lastPrice;
            } else {
                Serial.println("Failed to extract price.");
            }
        } else {
            Serial.println("Failed to fetch stock data, HTTP code: " + String(httpCode));
        }

        http.end();
    } else {
        Serial.println("Connection to stock server failed");
    }
}


void fetchUsdJpyData() {
  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;

  Serial.println("Connecting to Yahoo Finance for USD/JPY...");

  if (http.begin(client, usdJpyURL)) {
    int httpCode = http.GET();

    if (httpCode > 0) {
      Serial.println("USD/JPY HTTP GET succeeded, code: " + String(httpCode));

      float usdJpyLastPrice = extractPrice(client, true);

      if (usdJpyLastPrice != 0.0) {
        Serial.print("USD/JPY: ");
        Serial.println(usdJpyLastPrice);

        // 配列を左にシフト
        for (int i = 0; i < 99; i++) {
          usdJpyPrices[i] = usdJpyPrices[i + 1];
        }
        // 新しい値を配列の最後に追加
        usdJpyPrices[99] = usdJpyLastPrice;

        usdJpyPrice = usdJpyLastPrice;
      } else {
        Serial.println("Failed to extract USD/JPY price.");
      }
    } else {
      Serial.println("Failed to fetch USD/JPY data, HTTP code: " + String(httpCode));
    }

    http.end();
  } else {
    Serial.println("Connection to USD/JPY server failed");
  }
}



void showStockInfo(float* prices) {
  display.clearDisplay();
  display.setTextColor(WHITE);
  display.setTextSize(1);

  // ラベルを最上部に表示
  display.setCursor(0, 0);
  display.print(stockName);

  // 現在の価格を表示
  display.setTextSize(2);
  display.setCursor(0, 10);
  display.print(prices[99]);  // 最新の値を表示

  // 市場の状態を一つ下に表示
  display.setTextSize(1);
  if (isMarketOpen()) {
    display.setCursor(SCREEN_WIDTH - 30, 10);
    display.print("Open");
  } else {
    display.setCursor(SCREEN_WIDTH - 30, 10);
    display.print("Close");
  }

  // グラフの枠を描画
  int graphTop = 26;
  int graphBottom = SCREEN_HEIGHT - 16;  // グラフの縦の長さを大きく調整
  int graphHeight = graphBottom - graphTop;
  display.drawRect(0, graphTop, SCREEN_WIDTH, graphHeight, WHITE);

  // Y軸の最小値と最大値を求める
  float minPrice = prices[0];
  float maxPrice = prices[0];
  for (int i = 1; i < 100; i++) {
    if (prices[i] < minPrice) minPrice = prices[i];
    if (prices[i] > maxPrice) maxPrice = prices[i];
  }

  // Y軸の範囲を計算
  float range = (maxPrice - minPrice) * scaleFactor;
  if (range == 0) range = 1; // Division by zero を防ぐ
  float scale = (graphHeight - 2) / range; // 2 は上下のマージン

  // グラフを描画
  for (int i = 0; i < 99; i++) {
    int x0 = (i * (SCREEN_WIDTH - 2)) / 99; // X軸を調整して四角内に収める
    int y0 = graphBottom - scale * (prices[i] - minPrice); // Y軸を調整して四角内に収める
    int x1 = ((i + 1) * (SCREEN_WIDTH - 2)) / 99;
    int y1 = graphBottom - scale * (prices[i + 1] - minPrice);
    display.drawLine(x0, y0, x1, y1, WHITE);
  }

  // 最小値と最大値を四角の下に表示
  display.setTextSize(1);
  display.setCursor(0, graphBottom + 2); // 四角の下に移動して改行せずに表示
  display.print("Min: ");
  display.print(minPrice);
  display.setCursor(SCREEN_WIDTH - 50, graphBottom + 2); // 四角の右下に移動
  display.print("Max: ");
  display.print(maxPrice);

  display.display();
}





void showUsdJpyInfo() {
  display.clearDisplay();
  display.setTextColor(WHITE);
  display.setTextSize(1);

  // ラベルを最上部に表示
  display.setCursor(0, 0);
  display.print("USD/JPY");

  // 現在の価格を表示
  display.setTextSize(2);
  display.setCursor(0, 10);
  display.print(usdJpyPrices[99]);  // 最新の値を表示

  // グラフの枠を描画
  int graphTop = 26;
  int graphBottom = SCREEN_HEIGHT - 16;  // グラフの縦の長さを大きく調整
  int graphHeight = graphBottom - graphTop;
  display.drawRect(0, graphTop, SCREEN_WIDTH, graphHeight, WHITE);

  // Y軸の最小値と最大値を求める
  float minPrice = usdJpyPrices[0];
  float maxPrice = usdJpyPrices[0];
  for (int i = 1; i < 100; i++) {
    if (usdJpyPrices[i] < minPrice) minPrice = usdJpyPrices[i];
    if (usdJpyPrices[i] > maxPrice) maxPrice = usdJpyPrices[i];
  }

  // Y軸の範囲を計算
  float range = (maxPrice - minPrice) * scaleFactor;
  if (range == 0) range = 1; // Division by zero を防ぐ
  float scale = (graphHeight - 2) / range; // 2 は上下のマージン

  // グラフを描画
  for (int i = 0; i < 99; i++) {
    int x0 = (i * (SCREEN_WIDTH - 2)) / 99; // X軸を調整して四角内に収める
    int y0 = graphBottom - scale * (usdJpyPrices[i] - minPrice); // Y軸を調整して四角内に収める
    int x1 = ((i + 1) * (SCREEN_WIDTH - 2)) / 99;
    int y1 = graphBottom - scale * (usdJpyPrices[i + 1] - minPrice);
    display.drawLine(x0, y0, x1, y1, WHITE);
  }

  // 最小値と最大値を四角の下に表示
  display.setTextSize(1);
  display.setCursor(0, graphBottom + 2); // 四角の下に移動して改行せずに表示
  display.print("Min: ");
  display.print(minPrice);
  display.setCursor(SCREEN_WIDTH - 50, graphBottom + 2); // 四角の右下に移動
  display.print("Max: ");
  display.print(maxPrice);

  display.display();
}






bool isMarketOpen() {
  time_t now = time(nullptr);
  struct tm* timeinfo = localtime(&now);

  int hour = timeinfo->tm_hour;
  int minute = timeinfo->tm_min;
  int wday = timeinfo->tm_wday;

  if (stockName == "Nikkei 225") {
    if (wday != 0 && wday != 6) {
      if ((hour >= 9 && hour < 11) || (hour == 11 && minute < 30)) return true;
      if ((hour >= 12 && hour < 15)) return true;
    }
    return false;
  }

  if (stockName == "Dow Jones") {
    if (wday != 0 && wday != 6) {
      if ((hour >= 23) || (hour < 6)) return true;
    }
    return false;
  }

  return true;
}

void showWeatherInfo() {
  display.clearDisplay();
  display.setTextColor(WHITE);
  display.setTextSize(1);

  if (!weatherDataReady) {
    display.setCursor((SCREEN_WIDTH - 66) / 2, (SCREEN_HEIGHT - 8) / 2);
    display.print("Now Loading...");
  } else {
    display.setCursor(0, 0);
    display.print("Weather: " + weatherMain);

    display.setCursor(0, 10);
    display.print("Desc: " + weatherDescription);

    display.setCursor(0, 20);
    display.print("Temp: " + String(temperature) + " C");

    display.setCursor(0, 30);
    display.print("Humidity: " + String(humidity) + " %");

    display.setCursor(0, 40);
    display.print("Pressure: " + String(pressure) + " hPa");

    display.setCursor(0, 50);
    display.print("Wind: " + String(windSpeed) + " m/s");

    display.setCursor(64, 50);
    display.print("Clouds: " + String(cloudiness) + " %");
  }
  
  display.display();
}

void updateTime() {
  configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  while(!time(nullptr)) {
    Serial.println("Waiting for time");
    delay(1000);
  }

  lastUpdateTime = millis();
}
2
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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?