やりたいこと
水冷PCの水温に応じてラジエーターのファンの回転数をコントロールしたい。
ファン制御はPCとは分離して、独立した環境で動作させたい。
使用する機器と構成
今回はM5StackのATOMS3を使い、サーミスターの付いた温度センサーと接続して水温を測定します。サーミスターは10KΩのタイプで、10KΩの抵抗と分圧して使用します。なるべく配線を減らしたかったので、電源と入出力はGroveコネクタを使用しました。
主な機能
- 水温に応じてファンの回転数を制御する(自動制御)
- ボタン操作により任意の回転数を制御する(マニュアル制御)
- Webブラウザから状態の確認や、設定の変更ができる
- EEPROMに設定値を保存し、電源再投入後も前回の設定を維持できる
配線図
今回使用したBarrowのラジエーターキットには独自のファンコントローラーがついており、5Vの電源はファンコントローラーから取ります。PWMの信号は本来はマザーボードから取るようになっているのですが、直接ATOMS3と接続するようにします。
プログラム
/*
* サーミスタの温度に応じてファン制御用の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. > " : "<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コンバーターの値を計算させてましたが、これは楽でいいですね。電圧がわれば抵抗値が求められます。
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アドレスにすればアクセスできるはずです。
完成!
ということでこれで完成です!実際に水温を測定してみると、水冷用のフィッティングに取り付けるタイプの温度センサーは反応が遅めです。実際の温度より数分遅れて反応する感じです。直接サーミスタが水に接している構造ではないので、熱が伝わるまで時間がかかるのかもしれません。とりあえずこれで爆熱PCの夏対策の準備は整いました!