見出し画像

【電子工作】卓上電力メータを作る

1.8インチのTFT液晶画面を購入しました。

意外と小さい。

これと、ESP32を使って、家庭の消費電力を表示するメータを作ります。

目的

最近暑くなってきましたね。
この季節は、エアコンを動かさないと命にかかわります。

エアコンは快適さを提供しますが、大きな電力を消費し、家計や契約電力を圧迫します。エアコン使用中に電子レンジやドライヤーなどの消費電力が大きい家電を使うと、ブレーカが落ちることがあります。これが家電に悪影響を与えたり、作業が中断したりします。ブレーカが落ちるのを避けるため、今の消費電力を見える化するメータを作ろうと思います。

製作

要求仕様的な

  1. パッと見で今の消費電力を把握できるようにする

  2. データの鮮度(いつのデータか)を確認できるようにする

1.については、とにかく今家電を使っても大丈夫そうかをすぐに判断できることが大事なので、細かいデータの表示よりも見やすさを優先します。
2.については、消費電力のデータは約10秒ごとにしか取得できないためです。
スマートメータからBルート通信でデータを取得しますが、リアルタイムでの取得はできず、一定の間隔が必要です。また、通信が不安定でデータ取得に失敗することも多いため、表示されるデータのタイムスタンプを確認する必要があります。

表示部のデザイン

下記のようなデザインにします。
中央に大きく消費電力を表示します。
画面下側には、データ取得時のタイムスタンプと、取得してからの時間経過をプログレスバーで表示します。プログレスバーは10秒で一杯になり、その後時間経過に合わせて色が緑⇒黄⇒赤と遷移します。
ファイナルファンタジーの攻撃ができるようになるまでの時間を示すやつのイメージです。

パッと見はわかりやすいはず・・・

ある意味、消費電力は地球環境に与えるダメージなのかもしれません。
若干皮肉の効いたデザインとなってしまいました。

データ取得の仕組み

スマートメータとの通信はRaspberryPiで行います。
データの取得頻度は10秒に1回実施します。
その後、RaspberryPiから取得したデータをUDP通信で電力メータに送信し、メータの画面に表示します。

メータ制御プログラムの作成

メータ側の制御プログラムを作成します。
こちらはArduinoIDEで作成しました。

TFTの制御には、TFT_eSPIライブラリを使用しました。
TFTのドライバはST7735S、サイズは128×160ピクセルです。

#include <TFT_eSPI.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiUdp.h>

#define UDP_PORT (使用するポート番号)

// Wi-Fi設定
const char* ssid = "使用するWi-FiのSSID";          // Wi-FiのSSID
const char* password = "パスワード";          // Wi-Fiのパスワード

WiFiUDP udp;
TFT_eSPI tft = TFT_eSPI();  // Invoke library, pins defined in User_Setup.h

char ct_s[5];

// プログレスバーオブジェクトクラス
class progressbar{
  int pbWidth;
  int MAX_TIME;
  int DRAW_X;
  int DRAW_Y;
  int MAX_WIDTH;
  int HEIGHT;
  unsigned long updatedTime;

  public:
    // コンストラクタ
    progressbar(int maxWidth, int height, int draw_x, int draw_y, int maxTime){
      MAX_WIDTH = maxWidth;
      HEIGHT = height;
      DRAW_X = draw_x;
      DRAW_Y = draw_y;
      MAX_TIME = maxTime;
    }
  
    // プログレスバー描画(前回リセット時と現在の時間差からバーを描画する)
    void draw(){
      unsigned long timediff = millis() - updatedTime;
      if(timediff <= MAX_TIME){
        tft.fillRect(DRAW_X, DRAW_Y, calcWidth(timediff), HEIGHT, TFT_CYAN);
      } else if(timediff <= MAX_TIME * 2){
        tft.fillRect(DRAW_X, DRAW_Y, MAX_WIDTH, HEIGHT, TFT_GREEN);
      } else if(timediff <= MAX_TIME * 5){
        tft.fillRect(DRAW_X, DRAW_Y, MAX_WIDTH, HEIGHT, TFT_YELLOW);
      } else {
        tft.fillRect(DRAW_X, DRAW_Y, MAX_WIDTH, HEIGHT, TFT_RED);
      }
    }

    //リセット(データ取得時)
    void reset(){
      tft.fillRect(DRAW_X, DRAW_Y, MAX_WIDTH, HEIGHT, TFT_BLACK);
      updatedTime = millis();
    }

  private:
    // プログレスバー幅の計算
    int calcWidth(unsigned long timediff){
      float barWidthPixels;
      if(timediff < MAX_TIME){
        barWidthPixels = MAX_WIDTH * (float)timediff / (float)MAX_TIME;
      } else {
        barWidthPixels = MAX_WIDTH;
      }
      return (int)barWidthPixels;
    }
};

// UDP通信処理系クラス
class udpParser{
  private:
    char incomingPacket[255];

  public:
    char msgHeader[8+1];
    char datetimeData[32+1];
    char wattData[8+1];

  private:
    // UDP通信からデータを取得できたかを確認
    bool getPacket(){
      int packetSize = udp.parsePacket();
      if(packetSize){
        int len = udp.read(incomingPacket, 255);
        if(len > 0){
          incomingPacket[len] = 0;
          return true;
        } else {
          return false;
        }
      }
      return false;
    }

  public:
    // UDP通信で取得したメッセージをパース
    bool getData(){
      if(getPacket()){
        String message = incomingPacket;
        int firstComma = message.indexOf(",");
        int secondComma = message.indexOf(",", firstComma + 1);
        if(firstComma == -1 || secondComma == -1){
          return false;
        }
        String part1 = message.substring(0, firstComma);
        String part2 = message.substring(firstComma+1, secondComma);
        String part3 = message.substring(secondComma+1);
        strncpy(msgHeader, part1.c_str(), sizeof(msgHeader) - 1);
        strncpy(datetimeData, part2.c_str(), sizeof(datetimeData) - 1);
        strncpy(wattData, part3.c_str(), sizeof(wattData) - 1);
        if(strcmp(msgHeader, "ep") == 0) {
          return true;
        }
      }
      return false;
    }

};

// 画面初期化(メータ画面)
void displayInit(){
  char ipAddr[16+1];
  snprintf(ipAddr, sizeof(ipAddr), "%u.%u.%u.%u", WiFi.localIP()[0], WiFi.localIP()[1], WiFi.localIP()[2], WiFi.localIP()[3]);
  tft.fillScreen(TFT_BLACK);
  tft.drawLine(5, 11, 155, 11, TFT_WHITE);
  tft.drawLine(5, 100, 155, 100, TFT_WHITE);
  tft.drawString(ipAddr, 5, 1, 1);
  tft.setTextDatum(8);
  tft.drawString("---- ", 120, 82, 6);
  tft.setTextDatum(6);
  tft.drawString("W", 122, 79, 4);
  tft.setTextDatum(0);
  tft.drawString("Update:Not yet", 15, 105, 1);
}

// メータにデータ表示
void displayData(char datetimeData[32+1], char wattData[8+1]){
  tft.setTextDatum(8);
  // 消費電力大のとき文字の色を変える
  if(strtol(wattData, NULL, 10)>3500){
    tft.setTextColor(TFT_MAGENTA, TFT_BLACK);
  } else if(strtol(wattData, NULL, 10)>3000){
    tft.setTextColor(TFT_ORANGE, TFT_BLACK);
  } else if(strtol(wattData, NULL, 10)>2500){
    tft.setTextColor(TFT_YELLOW, TFT_BLACK);
  } else {
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
  }

  tft.fillRect(0, 32, 120, 50, TFT_BLACK);
  tft.drawString(wattData, 120, 82, 6);
  tft.setTextDatum(0);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.drawString(datetimeData, 57, 105, 1);
}


progressbar pb(160, 12, 0, 116, 10000);
udpParser udpData;

void setup() {
  int timeoutCount = 0;
  Serial.begin(115200);

  // 画面初期設定
  tft.init();
  tft.setRotation(1);
  tft.fillScreen(TFT_BLACK);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);

  // Wi-Fi接続
  WiFi.begin(ssid, password);
  tft.drawString("Connecting to WiFi...", 5 ,5, 1);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    timeoutCount++;
    if(timeoutCount > 20){
      tft.setTextColor(TFT_RED, TFT_BLACK);
      tft.drawString("Error:Failed to connect", 5, 15, 1);
      while(1){}
    }
  }
  tft.drawString("WiFi connected", 5, 15, 1);
  tft.drawString("IP Address: ", 5, 24, 1);
  char ipAddr[16];
  snprintf(ipAddr, sizeof(ipAddr), "%u.%u.%u.%u", WiFi.localIP()[0], WiFi.localIP()[1], WiFi.localIP()[2], WiFi.localIP()[3]);
  tft.drawString(ipAddr, 70, 24, 1);
  delay(3000);

  pb.reset();
  displayInit();
  udp.begin(UDP_PORT);
}

void loop() {
  if(udpData.getData()){
    displayData(udpData.datetimeData, udpData.wattData);
    pb.reset();
  }
  pb.draw();
}

データ送信プログラムの作成

送信側のプログラムはpythonで作成します。
こちらは、Bルート通信に関する部分を、Qiitaで見つけたプログラムをほぼそのまま使っているのですべてを載せることはできません。
UDPでデータ送信を行うところだけ載せておきます。
ほとんどChatGPTで作ってます…

# インポート
import socket

# UDP通信でデータ送信を行う関数
def udp_client_send_message(message, multicast_ip_address, port):
    # ソケットの作成
    client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    
    try:
        # ローカルアドレスにバインド(ポート0を使用するとシステムがポートを自動的に割り当てる)
        client_socket.bind(("", 0))
        # メッセージのエンコード
        message = message.encode('utf-8')
        # マルチキャストグループへの参加
        client_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
        # ポートを整数に変換
        port = int(port)
        # メッセージの送信
        client_socket.sendto(message, (multicast_ip_address, port))
        print(f"Message sent to {multicast_ip_address}:{port}")

    except ValueError:
        print("Port number must be an integer.")
    except PermissionError:
        print(f"Permission denied. Unable to send message to {ip_address}:{port}.")
    except socket.error as err:
        print(f"Socket error: {err}")
    finally:
        # ソケットのクローズ
        client_socket.close()

# ---メイン処理部分 --- 
# intPowerが取得した消費電力のデータ
datetimestring = datetime.datetime.now().strftime("%m/%d %H:%M:%S")
udpmessage = "ep," + datetimestring + "," + str(intPower)
udp_client_send_message(udpmessage, METER_HOST, METER_PORT)


完成品

百聞は一見に如かずなので、動いている様子を動画にしました。


まとめ

とりあえず動くところまで作れたのでよかったです。
やっぱり思い通りに動くと楽しいですね。
今後はケーシングを作ったり、ブザーを鳴らす機能を追加したりしようかと思います。

この記事が気に入ったらサポートをしてみませんか?