Arduinoで温度湿度計測モジュールを使用する

Arduino

温度湿度計測モジュール

別記事で、サーミスタという素子を利用した温度計測の実験を行いましたが、実は最近は温度・湿度を計測できるモジュールが販売されています。今回はその中でも安価で入手しやすいDHT11を利用した温度湿度計測の実験を行います。

温度湿度計測モジュールDHT11

概要

DHT11は、指先に乗るほどの小さなパッケージの中に温度センサ(NTCサーミスタ)・湿度センサ(有機ポリマー)と制御用マイクロコントローラを内蔵したセンサモジュールです。端子は4つしかなく、測定結果はデジタルデータとしてシリアルインターフェイスで送出するようになっています。

各端子の機能は以下の通りです。

名称機能
VDD電源供給(+)端子。標準電圧は5V(3.3~5.5V可)
I/Oシリアル信号端子。1つの端子で送受信を兼ねる。5kΩ程度の抵抗でプルアップ推奨
NCNo Connecting=内部でどこにも接続されていない端子。配線不要
GND電源および信号グラウンド

NCにはなにも接続しないので、電源(VDD・GND)以外の配線は1端子のみです。

今回使用したのは、基板に装着された状態でArduino実験用キットに封入されていたものです。端子は3つです。単にNC端子が省略されているだけではなく、端子の並び順が異なっています。

※添付ドキュメントに端子配列の記載がなく、基板パターンで確認しました…

I/O端子のプルアップ用として10kΩのチップ抵抗が基板上に取り付けられており、外付け不要のようです。

これとは違う型式の基板に取り付けられた製品があった場合、端子の配列やプルアップ抵抗の有無などはデータシートを確認して下さい。

仕様

DHT11の仕様は以下の通りです。

項目内容
電源電圧3.3~5.5V
消費電流スタンバイ時:0.06mA
測定時:1.0mA
湿度測定範囲5~95%
湿度測定精度±5%(25℃のとき)
湿度測定時間6秒以内(25℃、風速1m/sのとき)
湿度分解能1%(8bit)
温度測定範囲-20~60℃
温度測定精度±2℃(25℃のとき)
温度測定時間10秒以内
温度分解能1℃(8bit)

データは湿度1%単位、温度1℃単位で送出されますが、精度は湿度±5%、温度±2℃であることに注意して下さい。

データ送信プロトコル

DHT11で測定を行う手順は以下の通りです。

1-a.マイコンがセンサモジュールに対して、送信要求として幅TbeのLOWを送信する
1-b.送信要求信号送出後、Tgoの間にマイコンは送信を終了し信号線を解放する
2-a.センサモジュールが幅TrelのLOWを送信する
2-b.センサモジュールが幅TrehのHIGHを送信する
3.以下を40bit分繰り返す
  ビットが0のとき、センサモジュールは幅TLOWのLOWと幅TH0のHIGHを送信する
  ビットが1のとき、センサモジュールは幅TLOWのLOWと幅TH1のHIGHを送信する
4.全ビット送信後、Tenの間にセンサモジュールは送信を終了し信号線を解放する

パルスのタイミングは下図のようになります。赤い部分はマイコンからセンサモジュールへの送信、青い部分はセンサモジュールからマイコンへの送信です。1本の信号線で送受信を切り替えるのが特徴です。

記号推奨時間(有効範囲)
Tbe20ms(10~50ms)
Tgo13μs(10~35μs)
Trel83μs(78~88μs)
Treh87μs(80~92μs)
Ten54μs(52~56μs)

データ本体の送信部分は以下のようになります。送信するビットが0でも1でも、必ず54μsのLOWおよび可変長のHIGHがセットになっています。
ビットが0であることを表す場合はHIGHの長さが24μs
ビットが1であることを表す場合はHIGHの長さが71μs
となります。

このパターンが40ビット分連続して送信されます。

記号推奨時間(有効範囲)
TLOW54μs(50~58μs)
TH024μs(23~27μs)
TH171μs(68~74μs)

送信される40bitの内訳は以下の通りです。

第0バイト湿度上位バイト湿度の整数部
第1バイト湿度下位バイト湿度の小数点以下(DHT11では常に0)
第2バイト温度上位バイト温度の整数部
第3バイト温度下位バイト最上位ビットは符号
下位7ビットは温度の小数部
第4バイトパリティバイトエラー確認用バイト

湿度(%)=第0バイト + 第1バイト×0.1

第3バイトの最上位ビットが
0 ならば、温度(℃)=  第2バイト+第3バイト*0.1
1 ならば、温度(℃)=ー(第2バイト+(第3バイト&0x7F)×0.1)

パリティは、第0バイト~第4バイトの値の合計の下位8ビット

となります。

製作・実験

回路

DHT11を使用する回路は非常にシンプルです。DHT11の電源はArduinoの5V端子から供給し、I/O端子からはArduinoの適当なデジタル入力端子(下の回路図ではD3)へ接続します。DHT11単体を使用している場合、I/O端子からの配線はデータシートの推奨に従って4.7kΩでプルアップします。

配線

写真で使用しているのはプルアップ用チップ抵抗が基板上に取り付け済のモジュールなので、外付け抵抗は不要です。ブレッドボードを使用せず、メスーオスのジャンパ線で直接Arduinoと接続しています。

プログラム

C++
#define SENSOR_PIN 3
#define TIMEOUT UINT32_MAX
#define MAXWIDTH 1000

uint8_t data[5];

void setup() {
  Serial.begin(9600);
}

void loop() {
  if(receiveData()){
    float temperature = getTemperature();
    float humidity = getHumidity();
    Serial.print("温度:");
    Serial.print(temperature, 1);
    Serial.print("℃、湿度:");
    Serial.print(humidity, 1);
    Serial.println("%");
  }
  delay(2000);
}

// 指定された信号レベル(HIGHまたはLOG)が続いたパルス幅を取得する補助関数
// 連続 MAXWIDTH を超えたらタイムアウトとみなし UINT32_MAX を返す
uint32_t getPulseWidth(bool value){
  uint32_t width=0;
  while(digitalRead(SENSOR_PIN)==value){
    if(width++ >= MAXWIDTH){
      return TIMEOUT;
    }
  }
  return width;
}

// DHT11からデータを受信する
bool receiveData(){
  // 待機状態で200ms待つ
  pinMode(SENSOR_PIN, INPUT_PULLUP);
  delay(200);

  // 幅Tbe(20ms)のLOWを出力
  pinMode(SENSOR_PIN, OUTPUT);
  digitalWrite(SENSOR_PIN, LOW);
  delay(20);

  // Arduino側を入力に切り替え(プルアップでLINEレベルはHIGHになる)、Tgo(13μs)だけ待つ
  pinMode(SENSOR_PIN, INPUT_PULLUP);
  delayMicroseconds(13);

  // ここから先は処理時間がクリティカルなので割り込みを禁止
  noInterrupts();

  // データに先立って幅Trel(≒80μs)のLOW
  // 実は幅はタイムアウト以外チェックしていない(手抜き)
  if(getPulseWidth(LOW)==TIMEOUT){
    return false;
  }
  // 続いて幅Treh(≒80μs)のHIGH
  // 実は幅はタイムアウト以外チェックしていない(手抜き)
  if(getPulseWidth(HIGH)==TIMEOUT){
    return false;
  }

  // データ本体を配列に取り込む
  uint32_t pulseWidth[80];
  for(int n=0; n<40; n++){
    // 各ビットは1組のLOW+HIGHで構成される
    pulseWidth[n*2]   = getPulseWidth(LOW);           // LOWの幅を記録
    pulseWidth[n*2+1] = getPulseWidth(HIGH);          // HIGHの幅を記録
  }

  // 処理時間がクリティカルな処理はここまでなので割り込み禁止を解除
  interrupts();

  // 各ビットが0または1を判断しつつdata[0]~data[4]に格納
  data[0]=data[1]=data[2]=data[3]=data[4]=0;          // 初期化
  for(int n=0; n<40; n++){
    uint32_t lowWidth  = pulseWidth[n*2];             // 第nビットのLOWの幅
    uint32_t highWidth = pulseWidth[n*2+1];           // 第nビットのHIGHの幅
    if(lowWidth==TIMEOUT || highWidth==TIMEOUT){      // タイムアウトしているデータがあったら取得失敗として終了
      return false;
    }
    data[n/8] <<= 1;                                  // 既に代入したビットを1つ左にシフト
    if(highWidth > lowWidth){
      data[n/8] |= 1;                                 // HIGHの幅がLOWの幅より広ければそのビットは1(上位ビットを壊さないようにorをとる)
    }                                                 // そうでなければ0だが明示的に代入する必要はない
  }

  // パリティチェック([0]~[3]の合計値の下位8bitが[4]と一致するはず)
  if(data[4] != ((data[0]+data[1]+data[2]+data[3]) & 0xff)){
    return false;
  }

  // ここまでたどり着けたら取得成功
  return true;
}

// 湿度の計算
float getHumidity(){
  return data[0] + data[1] * 0.1;
}

// 温度の計算
float getTemperature(){
  float result;
  result = data[2] + ((data[3] & 0x0f) * 0.1);      // 温度の絶対値を求める(絶対値の求め方は正でも負でも同じ)
  if(data[3] & 0x80){                               // data[3]の最上位ビットが1の場合は負値
    result = - result;                              // 符号を反転する
  }
  return result;  
}

プログラムの解説

定数定義
C++
#define SENSOR_PIN 3
#define TIMEOUT UINT32_MAX
#define MAXWIDTH 1000

定数を3つ定義しています。

SENSOR_PIN はセンサモジュールのI/O端子と接続するArduinoのデジタル入出力端子番号です。

TIMEOUT はタイムアウトまでの時間としてuint32_tの最大値を指定していますが、直接時間を表しているわけではなく、関数が返すエラーコードとして利用しています。

MAXCYCLES はパルス幅の最大値です。パルス幅は時間ではなく、入力端子の値を監視するループの繰り返し回数によって計測しています。その繰り返し回数がMAXCYCLESを超えたとき、タイムアウトと見なします。

グローバル変数
C++
uint8_t data[5];

data[5] はセンサから受信したデータを保持します。

setup()関数
C++
void setup() {
  Serial.begin(9600);
}

setup()関数は、Serial.begin(9600)によりPCにデータを送るためのシリアル通信の初期化を行っています。計測に関係ある処理はありません。

loop()関数
C++
void loop() {
  if(receiveData()){
    float temperature = getTemperature();
    float humidity = getHumidity();
    Serial.print("温度:");
    Serial.print(temperature, 1);
    Serial.print("℃、湿度:");
    Serial.print(humidity, 1);
    Serial.println("%");
  }
  delay(2000);
}

receiveData()関数はセンサからデータを取得する関数
getTemperature()関数はグローバル変数data[]に格納されたデータから気温を計算する関数
getHumidity()関数はグローバル変数data[]に格納されたデータから湿度を計算する関数
です。このプログラムの後半で定義しています。

receiveData()関数でデータの取得に成功したら、気温と湿度を計算してSerial.print()関数でPCに送信しています。なお、Serial.print()関数に第2引数を指定した場合、数値を表示する際の小数点以下の桁数を表します。

パルスの幅を計測する
C++
uint32_t getPulseWidth(bool value){
  uint32_t width=0;
  while(digitalRead(SENSOR_PIN)==value){
    if(width++ >= MAXWIDTH){
      return TIMEOUT;
    }
  }
  return width;
}

SENSOR_PIN で示される入力端子の値が引数 value と等しい値を保っていた時間を、『ループを何回繰り返したか』によって計測する関数です。正常終了した場合はループの繰り返し数を返します。

パルスの幅が非常に短いので、micros() 関数によって時刻を取得するのにかかる時間さえ誤差の原因になりかねないため、このような処理になっています。

入力端子の値を監視するwhileループの繰り返し回数が MAXWIDTH に達した場合、何らかの異常によりパルスが受信できていないと見なして TIMEOUT(エラーコード)を返します。

前回の計測から十分に間隔を空ける
C++
bool receiveData(){
  // 待機状態で200ms待つ
  pinMode(SENSOR_PIN, INPUT_PULLUP);
  delay(200);

測定開始前に、待機状態として、入出力端子を入力・プルアップ状態にしています。

DHT11は計測が遅く、連続して計測する場合は間隔を200ms以上空けることが推奨されているようですので、delay(200); でウェイトを入れています。

関数自体は返却値の型をboolで宣言していますが、計測に成功した場合は true、途中で何らかのエラーが発生した場合はその時点で処理を中断して false を返すようにしています。受信した測定結果は関数の返却値ではなくグローバル変数 data[] に格納します。

Arduinoからセンサモジュールへデータ送信依頼パルスを送る
C++
  // 幅Tbe(20ms)のLOWを出力
  pinMode(SENSOR_PIN, OUTPUT);
  digitalWrite(SENSOR_PIN, LOW);
  delay(20);

最初に、Arduinoからセンサモジュールに対してデータ送信依頼パルスを送ります。データ送信依頼パルスは、幅 Tbe(=10~50ms)のLOWパルスです(待機状態はHIGHが続いている)。

pinMode(SENSOR_PIN, OUTPUT); でArduinoの端子を出力に設定し、
digitalWrite(SENSOR_PIN, LOW); でLOWを出力し、
delay(20); で20ms待っています。

送受信の切り替え
C++
  // Arduino側を入力に切り替え(プルアップでLINEレベルはHIGHになる)、Tgo(13μs)だけ待つ
  pinMode(SENSOR_PIN, INPUT_PULLUP);
  delayMicroseconds(13);

pinMode(SENSOR_PIN, INPUT_PULLUP); でArduinoの入出力端子を入力に切り替えています。プルアップしているのでレベルはHIGHになります(データ送信依頼パルス終了の立ち上がり)。

その状態で delayMicroseconds(30); で30μsだけ待ちます(プロトコルの Tgo(=10~35μs)の部分)。その間にセンサモジュール側のI/O端子が出力に切り替わります。

割り込みの禁止
C++
  // ここから先はタイミングがクリティカルなので割り込みを禁止
  noInterrupts();

データ受信は1ビットあたり80μs~130μs程度という短い時間での処理のため、不規則に余分な処理が発生しないように noInterrupts() 関数を実行して割り込みを禁止します。

noInterrupts()関数の書式は以下の通りです。

void noInterrupts(void)

引数も返却値もありません。この関数を実行すると、割り込みによってバックグラウンドタスクが起動することがなくなります。処理時間の厳しいプログラムの実行中に思わぬ割り込み処理によって時間が使われてしまう心配が無くなる一方、主プログラムで監視している端子以外に到着したデータは無視されます。

データヘッダの受信
C++
  // データに先立って幅Trel(≒80μs)のLOW
  // 実は幅はタイムアウト以外チェックしていない(手抜き)
  if(getPulseWidth(LOW)==TIMEOUT){
    return false;
  }
  // 続いて幅Treh(≒80μs)のHIGH
  // 実は幅はタイムアウト以外チェックしていない(手抜き)
  if(getPulseWidth(HIGH)==TIMEOUT){
    return false;
  }

センサモジュールは、データ本体に先立って、
Trel(78~88μs)のLOW

Treh(80~92μs)のHIGH
を出力します。プログラムのこの部分では、LOW→HIGHと切り替わっていることを確認しています。実はちょっと手抜きで、時間が上記の範囲内に収まっているかの確認はしていません。データ本体に先立っていったんLOW→HIGHになる、ということさえ踏まえていれば、とりあえず受信はできるので。

ただし、時間が異常に長い場合はTIMEOUTとしてエラー終了します。

データ本体の受信
C++
  // データ本体を配列に取り込む
  uint32_t pulseWidth[80];
  for(int n=0; n<40; n++){
    // 各ビットは1組のLOW+HIGHで構成される
    pulseWidth[n*2]   = getPulseWidth(LOW);           // LOWの幅を記録
    pulseWidth[n*2+1] = getPulseWidth(HIGH);          // HIGHの幅を記録
  }

uint32_t pulseWidth[80]; はデータ本体を表すパルスの幅を格納する配列変数です。パルスの幅はgetPulseWidth()関数内でのループの繰り返し回数で表します。

1ビットを表すパルスは、幅TLOW(50~58μs)のLOWと、その後に続く可変幅のHIGHの組合せです。ビットの値が0の場合、HIGHの幅はTH0(23~27μs)、ビットの値が1の場合、HIGHの幅はTH1(68~74μs)となります。

処理時間が足りなくなる可能性があるので、ここではビットが0か1かの判断を行わず、ひたすらパルス幅を配列に格納することに専念しています。pulseWidth[n*2]にはn番目のビットのLOWの幅、pulseWidth[n*2+1]にはn番目のビットのHIGHの幅が格納されます。データは全部で40ビットなので、配列の要素数は80です。

割り込み禁止の解除
C++
  // タイミングがクリティカルな処理はここまでなので割り込み禁止を解除
  interrupts();

処理時間が厳しい処理はここまでなので、interruputs()関数を実行して割り込み禁止を解除します。

interrupts()関数の書式は以下の通りです。

void interrupts(void)

引数も返却値もありません。この関数を実行すると、割り込み禁止が解除されます。

ビットの値の判断
C++
  // 各ビットが0または1を判断しつつdata[0]~data[4]に格納
  data[0]=data[1]=data[2]=data[3]=data[4]=0;          // 初期化
  for(int n=0; n<40; n++){
    uint32_t lowWidth  = pulseWidth[n*2];             // 第nビットのLOWの幅
    uint32_t highWidth = pulseWidth[n*2+1];           // 第nビットのHIGHの幅
    if(lowWidth==TIMEOUT || highWidth==TIMEOUT){      // タイムアウトしているデータがあったら取得失敗として終了
      return false;
    }
    data[n/8] <<= 1;                                  // 既に代入したビットを1つ左にシフト
    if(highWidth > lowWidth){
      data[n/8] |= 1;                                 // HIGHの幅がLOWの幅より広ければそのビットは1(上位ビットを壊さないようにorをとる)
    }                                                 // そうでなければ0だが明示的に代入する必要はない
  }

この部分では、40ビット分のパルス幅から、各ビットの値を判断しています。

まず、 data[0]=data[1]=data[2]=data[3]=data[4]=0; で結果を格納する配列の中身をすべてクリアします。

pulseWidth[n*2] には第nビットのLOWの幅、pulseWidth[n*2+1] には第nビットのHIGHの幅が格納されていますが、このいずれかがTIMEOUTだった場合はパルス幅の受信の時点で不具合が発生していたことになるので、エラー終了します。

次にビットの値を判断しますが、TH0TH1 の幅をμs単位で判断するのではなく、単に直前のLOWと較べて長いか短いかのみの判断として処理を簡略化しています。

データは8ビット(1バイト)ごとに配列変数 data[] に格納されます。
同じバイト内では上位ビットが先なので、新たなビットを追加する前にいままでにセットした分を順次左にシフトしていきながら、|=(論理和)を使ってビットを追加していきます。追加ビットが0の場合は左シフトのみを行います。

パリティチェック
C++
  // パリティチェック([0]~[3]の合計値の下位8bitが[4]と一致するはず)
  if(data[4] != ((data[0]+data[1]+data[2]+data[3]) & 0xff)){
    return false;
  }

最後にパリティチェックを行います。data[4] にはパリティバイトとして、送信側で data[0]data[3] の合計値の下位8ビットと同じ値がセットされています。受信側でも data[0]data[3] の合計値の下位8ビットを計算し、data[4] と一致するか確認しています。

一致しなかった場合は受信データが破損しているので、受信失敗としてエラー終了します。

受信成功時の処理
C++
  // ここまでたどり着けたら取得成功
  return true;
}

ここまでの処理がすべて行われたら、readData()関数の返却値としてtrueをセットし、data[]に格納されたデータが有効であることを呼び出し元に通知します。

湿度の計算
C++
// 湿度の計算
float getHumidity(){
  return data[0] + data[1] * 0.1;
}

受信が成功した場合、data[0] には湿度の整数部、data[1] には湿度の小数部が格納されています。getHumidity()関数は、data[0]data[1] に格納された値から湿度を求めます。

湿度(%)の値の計算方法は、

湿度 = data[0] + data[1]*0.1;

です。ただし、DHT11では湿度の分解能は1%なので(小数点以下がない)、data[1] の値は常に0になっているようです。同じシリーズのDHT22などとの互換性のために1バイト空けてあるのでしょう。

温度の計算
C++
// 温度の計算
float getTemperature(){
  float result;
  result = data[2] + ((data[3] & 0x0f) * 0.1);      // 温度の絶対値を求める(絶対値の求め方は正でも負でも同じ)
  if(data[3] & 0x80){                               // data[3]の最上位ビットが1の場合は負値
    result = - result;                              // 符号を反転する
  }
  return result;  
}

受信が成功した場合、data[2] には温度の整数部、data[3] は温度の小数部と符号が格納されています。getTemperature()関数は、data[2]data[3] に格納された値から温度(℃)を計算します。

温度には負値があるので、湿度よりも処理が多くなります。温度の計算方法は以下の通りです。

data[3] の最上位ビットが0の場合、温度は0℃以上
よって温度を求める式は以下のようになります。

温度 =   data[2] + (data[3] & 0x7F)*0.1;

data[3] の最上位ビットは符号を表しているので取り除いています(といっても正の場合は0なのでわざわざ取り除く必要はないのですが、負値の場合と扱いを同じにした方がプログラムはシンプルに書けるので)

data[3] の最上位ビットが1の場合、温度は負値
温度が負値のときも1の補数表現や2の補数表現ではなく、絶対値の求め方は温度が0℃以上の場合と同じです。よって、温度を求める式は以下のようになります。

温度 = -(data[2] + (data[3] & 0x7F)*0.1 );

なお、DHT11では小数点以下は1桁だけなので、data[3]のとりうる値は0~9および128~137だけだと思われます。

プログラムの実行

氷を詰めた保温ポットの中に、氷や金属の内壁に接触しないように注意しながらセンサを入れてみました。空気が流れていないためか変化がゆっくりしているので、蓋をした後は動画の速度を10倍にしています。

どこかでDHT11の温度分解能が1℃と見かけたような気がするのですが、この実験では小数点以下を含めて測定値が単調減少していますので、ちゃんと0.1℃単位で計測しているように思えます。手元に校正に使えるような温度計がないので精度は判りませんが。

ライブラリの利用

温度湿度計測モジュールDHTシリーズを利用するためのライブラリはいくつか見つかったのですが、『これが定番』というほど突出したものがないようなので、紹介は控えます。

まとめ

  • DHTシリーズは、温度と湿度の両方を計測できるコンパクトなセンサモジュールである
  • データはシリアル通信なので配線は電源+データの3本ですむ
  • データ転送が独自プロトコルなのでプログラムが複雑になる

コメント

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