ATOMS3でサーミスターの温度によりファンの回転数を制御する

# やりたいこと
水冷PCの水温に応じてラジエーターのファンの回転数をコントロールしたい。
ファン制御はPCとは分離して、独立した環境で動作させたい。

![atomfantop-s.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129769/3e10ce6a-151e-6230-39e5-0d79b4d88938.jpeg)

# 使用する機器と構成
- [ATOMS3](http://docs.m5stack.com/en/core/AtomS3)
- サーミスター 10KΩ（[水冷用](https://www.oliospec.com/item_search/rootCategoryId,47/childCategoryId,48/)）
- M5Stack用ミニプロトユニット、Grove互換ケーブル
- 抵抗 10KΩ
- PWM対応のファン

今回はM5StackのATOMS3を使い、サーミスターの付いた温度センサーと接続して水温を測定します。サーミスターは10KΩのタイプで、10KΩの抵抗と分圧して使用します。なるべく配線を減らしたかったので、電源と入出力はGroveコネクタを使用しました。

# 主な機能
- 水温に応じてファンの回転数を制御する（自動制御）
- ボタン操作により任意の回転数を制御する（マニュアル制御）
- Webブラウザから状態の確認や、設定の変更ができる
- EEPROMに設定値を保存し、電源再投入後も前回の設定を維持できる

# 配線図
```mermaid
graph LR
  G(GND)---T[サーミスター 10K]---G1([GPIO 1])---R[抵抗 10K]---VCC(5V)
  G2([GPIO 2])---PWM([FAN PWM pin])
```

今回使用したBarrowのラジエーターキットには独自のファンコントローラーがついており、5Vの電源はファンコントローラーから取ります。PWMの信号は本来はマザーボードから取るようになっているのですが、直接ATOMS3と接続するようにします。

# プログラム
```c++:Thermistor2FanPWM
/*
* サーミスタの温度に応じてファン制御用のPWMを出力する ver.0.3
*
* 使い方
*   ボタン長押しで Auto / Manual モード切り替え
*   短押しでファン回転数変更（Manualモード時のみ）
*   5秒長押しで強制リセット
*   ボタン押しながら起動するとEEPROMを初期値に戻す
*   ブラウザからの設定URL http://atomfan.local/conf
*
* 配線方法
*   GND ---- サーミスタ(10KΩ) ---- GPIO1 ---- 抵抗(10KΩ) ---- 5V
*   GPIO2 ---- FAN PWM pin
*/
#include <M5AtomS3.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <EEPROM.h>

// 設定 WiFi
const char* WIFI_SSID = "xxxx";
const char* WIFI_PASS = "xxxx";
#define MDNS_NAME "atomfan"   // mDNSに登録するホスト名
WiFiClient client;
WebServer server(80);

// 設定 ATOMS3
#define GPIO_FAN_PWM 2        // 出力ピン PWM
#define GPIO_THERMISTOR 1     // 入力ピン サーミスタ
#define PWM_FREQ 25000        // PWM周波数
#define PWM_BIT 8             // PWMの分解能bit
#define PWM_CH 0              // PWMチャンネル
#define ADC_RESOLUTIUON 4095  // ADCの分解能(0-4095)

// その他
#define EMARGENCY_TEMP 50     // 強制的にファンをフル回転にする温度
#define EEPROM_ADDRESS 0      // EEPROMに書き込む先頭アドレス
#define TEMP_HISTERESIS 0.8   // 温度判定にヒステリシスを持たせる範囲

// 設定 サーミスタの特性
#define THERMISTOR_R25  10000  // 25℃のときの抵抗値
#define THERMISTOR_B    3435   // B定数
#define THERMISTOR_RREF 10000  // リファレンス側の抵抗値

// 内部電圧の計算用 ATOMS3の5Vと3.3Vの電圧値（必要なら変更する）
#define M5ATOMS3_3V3 3.37   // (未使用)
#define M5ATOMS3_5V0 5.0//4.91

// デバッグに便利なマクロ定義 --------
#define sp(x) USBSerial.println(x)
#define spn(x) USBSerial.print(x)
#define spf(fmt, ...) USBSerial.printf(fmt, __VA_ARGS__)
#define lp(x) M5.Lcd.println(x)
#define lpn(x) M5.Lcd.print(x)
#define lpf(fmt, ...) M5.Lcd.printf(fmt, __VA_ARGS__)
#define array_length(x) (sizeof(x) / sizeof(x[0]))

// 関数定義
void lcdtext(String text, int size=-1, int x=-1, int y=-1);
void setFanDury(int ratio);
double measureTemperature(int count=10);
void eepromSave();
void eepromLoad();
void handleTop();
void handle404();

// グローバル変数
double temp = 0;
int fan = 100;
bool manualmode = false;
uint8_t conf[10] = {  // 温度設定 温度(以上になったら), 回転数(%)
  45, 100, 
  40,  70, 
  35,  50, 
  30,  20, 
   0,  10 
};

// EEPROMに保存するデータ
struct EepromData {
  uint8_t _check;
  int fan;
  bool manualmode;
  uint8_t conf[10];
};
EepromData eeprom;

// セットアップ
void setup() {
  M5.begin(true, true, false, false);  // Init M5AtomS3
  EEPROM.begin(sizeof(eeprom));
  M5.Lcd.setRotation(2);  // USBコネクタが下側

  // GPIOの設定
  pinMode(GPIO_THERMISTOR, INPUT);
  pinMode(GPIO_FAN_PWM, OUTPUT);
  ledcSetup(PWM_CH, PWM_FREQ, PWM_BIT);  // pwm ch0 setting
  ledcAttachPin(GPIO_FAN_PWM, PWM_CH);   // pwm ch0 attach GPIO
  setFanDury(100);

  // EEPROMの内容を初期値に戻す（ボタン押しながら起動）
  if (M5.Btn.isPressed()) {
    eepromSave();
    sp("EEPROM initialized");
  }

  // WiFi接続
  lp("Wifi connecting...");
  spn("Wifi connecting.");
  for (int j=0; j<10; j++) {
    WiFi.begin(WIFI_SSID, WIFI_PASS);  //  Wi-Fi APに接続
    for (int i=0; i<30; i++) {
      if (WiFi.status() == WL_CONNECTED) break;
      spn(".");
      delay(500);
    }
    if (WiFi.status() == WL_CONNECTED) {
      spn("connected! ");
      sp(WiFi.localIP());
      break;
    } else {
      sp("failed");
      WiFi.disconnect();
    }
    delay(2000);
  }

  // Webサーバーの設定
  if (WiFi.status() == WL_CONNECTED) {
    if (MDNS.begin(MDNS_NAME)) {  // http://atomfan.local/ でアクセスできるようにする
      sp("mDNS registerd!");
    } else {
      sp("mDNS error!");
      delay(2000);
    }
    server.on("/", []() { server.send(200, "text/plain", "OK"); });
    server.on("/conf", handleConf);
    server.on("/set", handleSet);
    server.onNotFound( []() { server.send(404, "text/plain", "Not Found\n\n"); });
    server.begin();
  }

  // EEPROMからデータを読み出す
  eepromLoad();
}

// メインループ
void loop() {
  M5.update();
  server.handleClient();

  // 1秒に1回行う処理
  bool refresh = false;
  static unsigned long nexttime = 0;
  if (nexttime < millis()) {
    nexttime = millis() + 1000;
    refresh = true;

    // 温度測定
    temp = measureTemperature();

    // 自動ファンコントロール
    if (!manualmode) {
      for (int i=0; i<array_length(conf); i+=2) {
        if ((fan == conf[i+1] && temp >= (conf[i]-TEMP_HISTERESIS)) || (temp >= conf[i])) {
          fan = conf[i+1];
          break;
        }
      }
    }
  }

  // マニュアルモード
  static bool lastPressed = false;
  if (M5.Btn.wasReleased() && lastPressed) {  // 長押し
    if (!manualmode) fan = 100;
    manualmode = !manualmode;
    refresh = true;
    eepromSave();  // EEPROMにデータを保存する
  } else if (manualmode && M5.Btn.wasReleased()) {  // 短押し
    fan -= 20;
    if (fan < 0) fan = 100;
    refresh = true;
    eepromSave();  // EEPROMにデータを保存する
  } else if (M5.Btn.pressedFor(5000)) {   // 超長押し
    M5.Lcd.clear();
    lcdtext("reboot", 2, 10, 10);
    while (!M5.Btn.wasReleased()) M5.update();
    ESP.restart();
  }
  lastPressed = M5.Btn.pressedFor(500);

  // 非常冷却モード
  if (temp >= EMARGENCY_TEMP && fan < 100) {
    fan = 100;
    refresh = true;
  }

  // ファンの回転数を変更
  if (refresh) {
    setFanDury(fan);
  }
  
  // LCDに温度とファンを表示
  if (refresh) {
    M5.Lcd.clear();
    lcdtext(String(int(round(temp))), 4, 20, 15);
    lcdtext(" O", 1);
    lcdtext("C", 2);
    lcdtext(String(fan), 4, 20, 64);
    lcdtext(" %", 2);
    lcdtext(manualmode ? "Manual" : "Auto", 2, 20, 110);
    lcdtext(" "+String(WiFi.localIP()[3]), 1);  // IPアドレスの最後を表示
  }
  delay(10);
}

// LCDにテキストを表示
void lcdtext(String text, int size, int x, int y) {
  static int oldsize;
  if (x != -1 && y != -1) M5.Lcd.setCursor(x, y);
  if (size != -1) M5.Lcd.setTextSize(size);
  M5.Lcd.print(text);
  oldsize = size;
}

// ファンの回転数を変更する(0%-100%)
void setFanDury(int ratio) {
  int pwm_duty = pow(2, PWM_BIT) * (ratio / 100.0);
  spf("temp=%.1f ratio=%d%% duty=%d\n", temp, ratio, pwm_duty);
  ledcWrite(PWM_CH, pwm_duty);
}

// 温度を測定する NTCサーミスタ
double measureTemperature(int count) {
  double tempsum = 0;
  for (int i=0; i<count; i++) {
    double adcmv = analogReadMilliVolts(GPIO_THERMISTOR); // 電圧測定
    double rt = adcmv / (M5ATOMS3_5V0 * 1000 - adcmv) * THERMISTOR_RREF;  // 抵抗値
    tempsum += 1.0 / (1.0 / (25.0+273.15) + 1.0 / THERMISTOR_B * log(rt / THERMISTOR_R25)) - 273.15;
    if (i==0) spf("adc=%.0f Rt=%.1f temp=%.1f\n", adcmv, rt, tempsum);
    delay(10);
  }
  return tempsum / count;
}

// EEPROMにデータを保存する
void eepromSave() {
  eeprom._check = 123;  // 未初期化データの読み込み防止用
  eeprom.fan = fan;
  eeprom.manualmode = manualmode;
  memcpy(eeprom.conf, conf, sizeof(conf));
  EEPROM.put(EEPROM_ADDRESS, eeprom);
  EEPROM.commit();
  sp("EEPROM saved");
}

// EEPROMからデータを読み出す
void eepromLoad() {
  EEPROM.get(EEPROM_ADDRESS, eeprom);
  if (eeprom._check != 123) {
    fan = eeprom.fan;
    manualmode = eeprom.manualmode;
    memcpy(conf, eeprom.conf, sizeof(eeprom.conf));
    sp("EEPROM loaded");
  }
}

// Webページ /conf ステータス表示と変更フォーム
void handleConf() {
  String html = "<html lang=\"ja\"><head><title>ATOMS3</title></head><body>\n\
    <h1>ATOMS3 Fan Controller</h1>\n\
    <p>Temperature: " + String(temp) + " \'C</p>\n\
    <p>Fan Speed: " + String(fan) + " %</p>\n\
    <p>Mode: " + (manualmode ? "Manual" : "Auto")+ "</p>\n\
    <hr><h2>Settings</h2>\n\
    <p><form action=\"/set\" method=\"POST\">\n\
    <input type=\"checkbox\" name=\"manual\" value=\"1\" " + String(manualmode ? "checked":"") + ">\n\
    Manual Mode (Fan <input type=\"text\" name=\"fan\" value=\""+ String(fan) +"\" size=\"4\">%)<br />\n\
    <table><tr><th>Temperature<th></th><th>Fan (%)</th></tr>\n";
  for (int i=0; i<array_length(conf); i++) {
    html += (i % 2 == 0) ? "<tr>\n<td>temp. &gt; " : "<td> to ";
    html += "<input type=\"text\" name=\"" + String(i) + "\" value=\"" + String(conf[i]) + "\" size=\"4\">";
    html += (i % 2 == 0) ? "</td>\n" : " %</td></tr>\n";
  }
  html += "</table>\n\
    <input type=\"submit\" name=\"save\" value=\"Save\">\n\
    </form></p>\n\
    </body></html>\n";
  server.send(200, "text/html", html);
  sp("Web: /conf");
}

// Webページ /set 設定変更
void handleSet() {
  if (server.method() == HTTP_POST) {
    manualmode = (server.arg("manual") == "1");
    fan = server.arg("fan").toInt();
    //if (manualmode) setFanDury(fan);  // ファンの回転数を変更
    sp("Set fan to "+String(fan));
    for (int i=0; i<array_length(conf); i++) {
      conf[i] = server.arg(String(i)).toInt();
    }
    eepromSave();  // EEPROMにデータを保存する
  }
  server.send(200, "text/html", "<head><meta http-equiv=\"refresh\" content=\"0;url=/conf\"></head>");
  sp("Web: /set");
}
```

## サーミスターの温度を求める方法
サーミスターは温度に応じて抵抗値が変化するパーツです。ATOMS3にはADCが内蔵されているので、まずは10KΩの抵抗と分圧しているGPIO1の電圧を求めます。analogReadMilliVolts()なんて便利な関数があるんですね！今までanalogRead()で求めたADコンバーターの値を計算させてましたが、これは楽でいいですね。電圧がわれば抵抗値が求められます。

```c++
double adcmv = analogReadMilliVolts(GPIO_THERMISTOR); // 電圧測定
double rt = adcmv / (M5ATOMS3_5V0 * 1000 - adcmv) * THERMISTOR_RREF;  // 抵抗値
temp = 1.0 / (1.0 / (25.0+273.15) + 1.0 / THERMISTOR_B * log(rt / THERMISTOR_R25)) - 273.15;
```

サーミスターの抵抗値から温度を求めるのはちょっと面倒です。計算方法はChatGPTに教えてもらいました。サーミスターには製品によって特性が異なり、B定数という値があります。ところが困ったことに、こういった製品の多くはB定数の記載がありません。仕方ないので実際に水とお湯の温度を測定してみて、温度が大きくずれない範囲の既製品の値にしてみました。精度はそれほど重要ではないのでだいたいあっていればOKです。

## ヒステリシス
温度が上がったときは即座にファンの回転数が上がりますが、下がった場合は 設定値-0.8℃ 以下にならないと回転数を下げないようになっています。これは閾値付近で頻繁に回転数の変更が起こらないように、動作に幅を持たせています。

# 使い方
ボタン長押しでAutoモードとManualモードが切り替わります。
Manualモード時はボタン短押しでファン回転数が変わります。
5秒長押しで強制リセット。
起動時にボタン押しっぱなしにしているとEEPROMが初期値に戻ります。

## Webブラウザからの設定
Webブラウザから設定画面にアクセスすることもできます。mDNSを使っているので以下のURLからアクセスできます。もしブラウザが非対応な場合は、画面の右下にある小さな数字がIPアドレスの最後の部分なので、atomfan.localの部分をプライベートIPアドレスにすればアクセスできるはずです。

http\://atomfan.local/conf
![FwZWKooacAAr50x-s.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129769/e8649308-f56d-3e12-f1a1-327f355db50b.png)

# 完成！
ということでこれで完成です！実際に水温を測定してみると、水冷用のフィッティングに取り付けるタイプの温度センサーは反応が遅めです。実際の温度より数分遅れて反応する感じです。直接サーミスタが水に接している構造ではないので、熱が伝わるまで時間がかかるのかもしれません。とりあえずこれで爆熱PCの夏対策の準備は整いました！
![atomfansystem-s.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129769/a8ed0486-f4a3-0dea-05b5-a7d61f35d655.jpeg)