LoginSignup
23
23

More than 3 years have passed since last update.

M5GO(M5Stack)で気温・湿度・気圧・CO2濃度メーター&時計表示

Last updated at Posted at 2019-04-19

【2019/06/09更新】M5 UI.Flow v1.3 で変更になった MicroPython に合わせて対応

  • buttonA, buttonB, buttonC → btnA, btnB, btnC
  • wifisetup → wifiCfg
  • rtc.ntp_sync → ntptime.py
  • time.strftime → .format で対応

概要

CO2濃度センサー MH-Z19B を購入したので、以前作成した「M5GO(M5Stack)で気温・湿度・気圧メーター&時計表示」 を元にCO2濃度メーター表示を追加しました。また、Ambientへの送信、ボタンAを押す事で各メーターを画面全体表示で切り替える、ボタンCを押す事でMH-Z19Bのゼロキャリブレーションを行う等の仕組みも実装しています。

環境

  • M5GO
  • M5 UI.Flow (Firmware v1.3.2-en)
  • 環境センサー(DHT12、BMP280)
  • CO2濃度センサー(MH-Z19B)
  • MicroPythonで記述

実行結果

全メーターと(無理やり)時計を表示
IMG_3457.jpeg IMG_3458.jpeg
(M5GO/M5Stack FIRE 付属のケーブル/説明書と市販のGrove用のケーブル(MH-Z19Bとの接続で使用)では黄と白のケーブルが逆になっているので要注意)

ボタンAを押す事で表示を切り替え
IMG_3459.jpeg IMG_3460.jpeg IMG_3461.jpeg IMG_3462.jpeg IMG_3463.jpeg

Ambientでのグラフ表示
(MH-Z19Bで取得できる気温は整数値なのでDHT12(小数点以下1桁) BMP280(小数点以下2桁)と比べると大雑把)
(DHT12で取得している湿度の値、以前は妥当な値でしたが、今回の値は別途 Raspberry Pi で取得している BME-280 での取得値と比べると20%程度低い値となってしまっています。センサーの問題なのか、原因不明です)
Ambient_thpc.png

MH-Z19B の接続

MH-Z19B は M5Stack の Port C (UART) に接続です。

PORT C MH-Z19B
GND GND
5V Vin
TXD (G17) RX
RXD (G16) TX

【注意】
「M5BALAに超音波距離センサーを載せて障害物回避」 にも書きましたが、M5GO/M5Stack FIRE 付属のケーブル/説明書ではケーブルの色が「黒、赤、黄、白」となっていますが、市販のGrove用のケーブルでは「黒、赤、白、黄」と黄と白が逆になっていますのでお気をつけて。今回も最初の接続時に間違えてしまい値が取得できませんでした。(学習してない…)

プログラム

取得データの Ambient へ送信してグラフ化を行うため以下を参考にしています。
M5StackでセンサーデーターをAmbientに送る (MicroPython編)

以下より ambient のMicroPython用モジュール ambient.py を取得
https://github.com/AmbientDataInc/ambient-python-lib

ampy を使って ambient.py を M5Stack に転送

$ ampy put ambient.py

使えなくなった rtc.ntp_sync の代わりに ntptime.py の修正版(タイムゾーンのオフセット指定を追加)を使用

ntptime.py
#
# 以下にタイムゾーンのオフセット指定を追加
# https://github.com/micropython/micropython/blob/master/ports/esp8266/modules/ntptime.py
#
try:
    import usocket as socket
except:
    import socket
try:
    import ustruct as struct
except:
    import struct

# (date(2000, 1, 1) - date(1900, 1, 1)).days * 24*60*60
NTP_DELTA = 3155673600

host = "pool.ntp.org"

def time():
    NTP_QUERY = bytearray(48)
    NTP_QUERY[0] = 0x1b
    addr = socket.getaddrinfo(host, 123)[0][-1]
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.settimeout(1)
    res = s.sendto(NTP_QUERY, addr)
    msg = s.recv(48)
    s.close()
    val = struct.unpack("!I", msg[40:44])[0]
    return val - NTP_DELTA

# There's currently no timezone support in MicroPython, so
# utime.localtime() will return UTC time (as if it was .gmtime())
#
# offset: timezone offset (sec)
#         settime(9*60*60) for JST
def settime(offset=0):
    t = time() + offset
    import machine
    import utime
    tm = utime.localtime(t)
    tm = tm[0:3] + (0,) + tm[3:6] + (0,)
    machine.RTC().datetime(tm)

ampy を使って ntptime.py を M5Stack に転送

$ ampy put ntptime.py
env_meter.py
# UI.Flow 1.2 以前は buttonA, buttonB, buttonC
# UI.Flow 1.3 以降は btnA, btnB, btnC
from m5stack import lcd, btnA, btnB, btnC
from dht12   import DHT12
from bmp280  import BMP280
import i2c_bus
import machine
import time
import math
import gc
import ambient

# Ambientで取得したチャネルのチャネルId, ライトキーを指定
AMBIENT_CHANNEL_ID = チャネルId
AMBIENT_WRITE_KEY  = 'ライトキー'

# MH-Z19B のCO2濃度取得コマンド
READ_CO2_CONCENTRATION = bytearray(b'\xff\x01\x86\x00\x00\x00\x00\x00\x79')
# MH-Z19B のゼロキャリブレーションコマンド
ZERO_POINT_CALIBRATION = bytearray(b'\xff\x01\x87\x00\x00\x00\x00\x00\x78')


class Meter:
    def __init__(self, x, y, w, h, tick_s, tick_e, color, title, value_format):
        self.x = x # メーターの表示位置
        self.y = y # メーターの表示位置
        self.w = w # メーターの表示幅
        self.h = h # メーターの表示高
        self.tick_s = tick_s              # 目盛の最小値
        self.tick_e = tick_e              # 目盛の最大値
        self.title  = title
        self.value_format = value_format  # 値をテキスト表示する際のフォーマット

        self.center_x = x + w // 2        # 針の原点
        self.center_y = y + int(h * 0.9)  # 針の原点
        self.prev_value = tick_s
        self.prev_angle = None

        lcd.roundrect(x, y, w, h, h // 10, lcd.BLACK, lcd.WHITE)
        lcd.arc(self.center_x, self.center_y, int(h * 0.67), int(h * 0.07), -50, 50, color, color)
        lcd.arc(self.center_x, self.center_y, int(h * 0.6), 2, -50, 50, lcd.BLACK)

        # 目盛の値表示用フォント設定
        if self.w == win_w:
            lcd.font(lcd.FONT_Default, transparent=False)
        else:
            lcd.font(lcd.FONT_DefaultSmall, transparent=True)

        fw, fh = lcd.fontSize()

        tick = tick_s
        tick_i = (tick_e - tick_s) // 4
        for r in range(-50, 51, 5):
            if r % 25 == 0:
                # 目盛の最小値から最大値を4分割して目盛値を表示
                lcd.lineByAngle(self.center_x - 1, self.center_y, int(h * 0.6), int(h * 0.1), r, lcd.BLACK)
                lcd.lineByAngle(self.center_x,     self.center_y, int(h * 0.6), int(h * 0.1), r, lcd.BLACK)
                tick_text = str(tick)
                text_width = lcd.textWidth(tick_text)
                lcd.print(tick_text, self.center_x + int(math.sin(math.radians(r)) * h * 0.7) - text_width // 2,
                                     self.center_y - int(math.cos(math.radians(r)) * h * 0.7) - fh,
                                     lcd.BLACK)
                tick += tick_i
            else:
                # 細かい目盛線を表示
                lcd.lineByAngle(self.center_x, self.center_y, int(h * 0.6), int(h * 0.05), r, lcd.BLACK)

    def update(self, value):
        # 取得値をテキストでも表示
        if self.w == win_w:
            lcd.font(lcd.FONT_DejaVu24, transparent=False)
        else:
            lcd.font(lcd.FONT_DejaVu18, transparent=False)

        fw, fh = lcd.fontSize()

        if value is not None:
            angle = int((value - self.tick_s) / (self.tick_e - self.tick_s) * 100 - 50)
            if angle != self.prev_angle:
                # 前回取得値の針を消去
                if self.prev_angle is not None:
                    for i in range(-1, 2):
                        lcd.lineByAngle(self.center_x + i, self.center_y, int(self.h * 0.15), int(self.h * 0.42),
                                        self.prev_angle, lcd.WHITE)
                # 今回取得値の針を表示
                for i in range(-1, 2):
                    lcd.lineByAngle(self.center_x + i, self.center_y, int(self.h * 0.15), int(self.h * 0.42),
                                    angle, lcd.RED)

                if self.title != '':
                    lcd.print(self.title, self.center_x - lcd.textWidth(self.title) // 2, self.y + self.h - int(fh * 2.4), lcd.BLACK)
                self.prev_angle = angle

            if value != self.prev_value:
                text = self.value_format.format(value)
                lcd.print(text, self.center_x - lcd.textWidth(text) // 2, self.y + self.h - int(fh * 1.2), lcd.BLACK)
                self.prev_value = value
        else:
            text = self.value_format.format(self.prev_value)
            lcd.print(text, self.center_x - lcd.textWidth(text) // 2, self.y + self.h - int(fh * 1.2), lcd.RED)


class Clock_digital:
    def __init__(self, x, y):
        self.x = x # 時計の表示位置
        self.y = y # 時計の表示位置

    def update(self):
        lcd.font(lcd.FONT_DejaVu18, transparent=False)
        lcd.print('{:02d}:{:02d}:{:02d}'.format(*time.localtime()[3:6]), self.x, self.y, lcd.BLACK)


class Clock:
    def __init__(self, x, y, w, h, color):
        self.x = x # 時計の表示位置
        self.y = y # 時計の表示位置
        self.w = w # 時計の表示幅
        self.h = h # 時計の表示高
        self.center_x = x + w // 2 # 針の中心
        self.center_y = y + h // 2 # 針の中心
        self.hour_deg = 0
        self.minute_deg = 0
        self.second_deg = 0

        lcd.roundrect(x, y, w, h, h // 10, lcd.BLACK, lcd.WHITE)
        # 0 から 360 とは書けないので、半分の円弧を合わせる
        lcd.arc(self.center_x, self.center_y, int(h * 0.39), int(h * 0.08),   0, 180, color, color)
        lcd.arc(self.center_x, self.center_y, int(h * 0.39), int(h * 0.08), 180, 360, color, color)

        if self.w == win_w:
            lcd.font(lcd.FONT_Default, transparent=False)
        else:
            lcd.font(lcd.FONT_DefaultSmall, transparent=True)

        fw, fh = lcd.fontSize()
        hour = 12
        for r in range(0, 360, 360 // 60):
            if r % (360 // 12) == 0:
                # 1〜12の位置に黒点および数字を表示
                lcd.circle(self.center_x + int(math.sin(math.radians(r)) * h / 2 * 0.7),
                           self.center_y - int(math.cos(math.radians(r)) * h / 2 * 0.7), 2, lcd.BLACK, lcd.BLACK)
                hour_text = str(hour)
                text_width = lcd.textWidth(hour_text)
                lcd.print(hour_text, self.center_x + int(math.sin(math.radians(r)) * h / 2 * 0.85) - text_width // 2,
                                     self.center_y - int(math.cos(math.radians(r)) * h / 2 * 0.85) - fh // 2,
                                     lcd.BLACK)
                hour = (hour + 1) % 12
            else:
                lcd.pixel(self.center_x + int(math.sin(math.radians(r)) * h / 2 * 0.7),
                          self.center_y - int(math.cos(math.radians(r)) * h / 2 * 0.7), lcd.BLACK)

    def update(self):
        def needle(n, m, deg, l, color):
            for i in range(n, n + m):
                if deg >= 315 or deg < 45 or deg >= 135 and deg < 225:
                    x, y = i, 0
                else:
                    x, y = 0, i
                lcd.lineByAngle(self.center_x + x, self.center_y + y,
                                0, l, deg, color)

        # 時分秒の各針の角度を計算
        (year, month, mday, hour, minute, second, weekday, yearday) = time.localtime()
        second_deg = second * 6
        minute_deg = minute * 6 + second_deg // 60
        hour_deg   = hour % 12 * 30 + minute_deg // 12

        # 時針の消去(角度が変わっていないときは消さない)
        if hour_deg != self.hour_deg:
            needle(-2, 4, self.hour_deg, int(self.h / 2 * 0.3), lcd.WHITE)
        # 分針の消去(角度が変わっていないときは消さない)
        if minute_deg != self.minute_deg:
            needle(-1, 2, self.minute_deg, int(self.h / 2 * 0.45), lcd.WHITE)
        # 秒針の消去
        needle(0, 1, self.second_deg, int(self.h / 2 * 0.6), lcd.WHITE)

        self.second_deg = second_deg
        self.minute_deg = minute_deg
        self.hour_deg = hour_deg

        # 時針の描画(4本線)
        needle(-2, 4, hour_deg, int(self.h / 2 * 0.3), lcd.BLACK)
        # 分針の描画(2本線)
        needle(-1, 2, minute_deg, int(self.h / 2 * 0.45), lcd.BLACK)
        # 秒針の描画(1本線)
        needle(0, 1, self.second_deg, int(self.h / 2 * 0.6), lcd.RED)
        # 中心に赤丸
        lcd.circle(self.center_x, self.center_y, 3, lcd.RED, lcd.RED)


def mhz19_checksum(value):
    s = 0
    for v in value[1:8]:
        s = (s + v) & 0xff
    return (0xff - s + 1) & 0xff


def mhz19_response(mhz19_value, checksum):
    print('{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format(*time.localtime()[:6]), ' Script Name: ', __name__)
    print('{} [x{:02x}]'.format(' '.join('x{:02x}'.format(v) for v in mhz19_value), checksum))


def env_meter_update(meter_mode):
    while True:
        # 次の秒までの差分(ミリ秒)を求めてスリープ
        time.sleep_ms(1000 - int(time.time() % 1 * 1000))

        # 各メーターおよび時計の更新
        localtime = time.localtime()
        localtime_str = '{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format(*localtime[:6])

        # 時計の表示を更新
        clock.update()

        try:
            # DHT12 から湿度を取得
            dht12.measure()
            h = dht12.humidity()

            # BMP280 から気温、気圧を取得
            t, p = bmp280.values
        except Exception as e:
            print('{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format(*time.localtime()[:6]), ' Script Name: ', __name__)
            print('Exception: ', e)
            t, h, p = None, None, None

        # MH-Z19B からCO2濃度・気温を取得
        mhz19_value = bytearray(9)
        mhz19.write(READ_CO2_CONCENTRATION)
        mhz19.readinto(mhz19_value, len(mhz19_value))
        checksum = mhz19_checksum(mhz19_value)
        c = None
        if mhz19_value[0] == 0xff and mhz19_value[1] == 0x86 and mhz19_value[8] == checksum:
            # CO2濃度を取得
            c = mhz19_value[2] * 256 + mhz19_value[3]
            # 気温を取得
            mhz19_t = mhz19_value[4] - 40
        else:
            mhz19_response(mhz19_value, checksum)

        # それぞれのメーターに取得値を表示
        if meter_mode == 0 or meter_mode == 1:
            t_meter.update(t)
        if meter_mode == 0 or meter_mode == 2:
            h_meter.update(h)
        if meter_mode == 0 or meter_mode == 3:
            p_meter.update(p)
        if meter_mode == 0 or meter_mode == 4:
            c_meter.update(c)

        # Ambientへの送信は1分間隔で行う。
        # localtime[5](秒) == 0 の時に送信
        if localtime[5] == 0 and t is not None:
            try:
                if c is None:
                    am.send({'created': localtime_str,
                            'd1': dht12.temperature(), 'd2': h, 'd3': t, 'd4': p})
                else:
                    am.send({'created': localtime_str,
                            'd1': dht12.temperature(), 'd2': h, 'd3': t, 'd4': p, 'd6': c, 'd7': mhz19_t})
            except Exception as e:
                print('{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format(*time.localtime()[:6]), ' Script Name: ', __name__)
                print('Ambient send error: ', e)

        gc.collect()

        # button A が押された時は表示を切り替える
        if btnA.wasPressed():
            return

        # button C が押された時は MH-Z19B のゼロキャリブレーションを行う
        if btnC.wasPressed():
            mhz19.write(ZERO_POINT_CALIBRATION)
            mhz19.readinto(mhz19_value, len(mhz19_value))
            checksum = mhz19_checksum(mhz19_value)
            mhz19_response(mhz19_value, checksum)


# M5 UI.Flow からの実行ではなく、APP.LIST に登録して実行する場合は、
# プログラム内でネットワーク接続を行う必要がある。
# Connect network
# 
# UI.Flow 1.2 以前は以下
# import wifisetup
# wifisetup.auto_connect()
#
# UI.Flow 1.3 以降は以下
import wifiCfg
wifiCfg.autoConnect(lcdShow=True)

# 日本時間に同期
# UI.Flow 1.2 以前は以下
'''
rtc = machine.RTC()
rtc.ntp_sync('ntp.nict.jp', tz='JST-9')
# M5GOのfirmwareがv0.11ではntp_syncでtzを指定するとエラーになるので以下で対応
# rtc.ntp_sync('ntp.nict.jp')
# sys.tz('JST-9')

# 同期が完了するまで100ms程度かかる
for i in range(100):
    if rtc.synced():
        print('synced.')
        break
    print(i, end=' ')
    time.sleep_ms(10)
'''
# UI.Flow 1.3 以降は以下
# ntptime は以下のモジュールの改訂版(オフセット指定を追加)
# https://github.com/micropython/micropython/blob/master/ports/esp8266/modules/ntptime.py
import ntptime
ntptime.settime(9*60*60) # +09:00:00 for JST

# UI.Flow 1.2 以前はタイムスタンプの出力に以下のように time.strftime を使用していたが
#print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()), ' Script Name: ', __name__)
# UI.Flow 1.3 以降は time.strftime が使えないため以下で対応
print('{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format(*time.localtime()[:6]), ' Script Name: ', __name__)

am = ambient.Ambient(AMBIENT_CHANNEL_ID, AMBIENT_WRITE_KEY)

i2c = i2c_bus.get(i2c_bus.M_BUS)
dht12 = DHT12(i2c)
bmp280 = BMP280(i2c)

mhz19 = machine.UART(2, tx=17, rx=16)
mhz19.init(9600, bits=8, parity=None, stop=1)

lcd.setColor(lcd.BLACK, lcd.WHITE)
lcd.setTextColor(lcd.BLACK, lcd.WHITE)
lcd.clear(lcd.BLACK)

win_w, win_h = lcd.winsize() # (320, 240)

meter_mode = 0
while True:
    lcd.clear(lcd.BLACK)

    if meter_mode == 0:
        # 画面を4分割して、気温計、湿度計、気圧計、CO2濃度計を表示
        # 表示フォーマットは、表示桁数が減った時に(気圧が1000から999になった時等)
        # 前の表示を消すために前後に空白を入れている
        t_meter = Meter(0,          0,          win_w // 2, win_h // 2,   0,   40, lcd.ORANGE,      'Temp', ' {:.1f}C ')
        h_meter = Meter(win_w // 2, 0,          win_w // 2, win_h // 2,  20,  100, lcd.CYAN,        'Hum',  ' {:.1f}% ')
        p_meter = Meter(0,          win_h // 2, win_w // 2, win_h // 2, 960, 1040, lcd.YELLOW,      'Baro', ' {:.1f}hPa ')
        c_meter = Meter(win_w // 2, win_h // 2, win_w // 2, win_h // 2, 400, 1600, lcd.GREENYELLOW, 'CO2',  ' {:.0f}ppm ')
        # 中央に小さい時計を表示
        clock   = Clock(win_w // 2 - win_w // 8, win_h // 2 - win_h // 8 - 10, win_w // 4, win_h // 4, lcd.LIGHTGREY)
        # clock   = Clock_digital(win_w // 2 - 43, win_h // 2 - 8)
    elif meter_mode == 1:
        # 全画面で気温計を表示
        t_meter = Meter(0, 0, win_w, win_h,   0,   40, lcd.ORANGE,      'Temp', ' {:.1f}C ')
        clock   = Clock_digital(win_w // 2 - 43, 10)
    elif meter_mode == 2:
        # 全画面で湿度計を表示
        h_meter = Meter(0, 0, win_w, win_h,  20,  100, lcd.CYAN,        'Hum',  ' {:.1f}% ')
        clock   = Clock_digital(win_w // 2 - 43, 10)
    elif meter_mode == 3:
        # 全画面で気圧計を表示
        p_meter = Meter(0, 0, win_w, win_h, 960, 1040, lcd.YELLOW,      'Baro', ' {:.1f}hPa ')
        clock   = Clock_digital(win_w // 2 - 43, 10)
    elif meter_mode == 4:
        # 全画面でCO2濃度計を表示
        c_meter = Meter(0, 0, win_w, win_h, 400, 1600, lcd.GREENYELLOW, 'CO2',  ' {:.0f}ppm ')
        clock   = Clock_digital(win_w // 2 - 43, 10)
    elif meter_mode == 5:
        # 全画面で時計を表示
        clock   = Clock(0, 0, win_w, win_h, lcd.LIGHTGREY)

    env_meter_update(meter_mode)

    meter_mode = (meter_mode + 1) % 6

補足

MH-Z19B User’s Manual にはCO2濃度読み出し時の応答データについて Byte4〜Byte7 の値の説明がありませんが、以下の Raspberry Pi用Pythonモジュールのサイトに参考になりそうな情報があります。こちらを参考に Byte4 - 40 を温度として取得し Ambient へ送信しています。
https://github.com/UedaTakeyuki/mh-z19

Byte6とByte7について実際に動かした時の変化を出力してみたところ、以下のような動きになっていました。

起動直後の読み出し時は All 0

2019-04-13 20:03:41  Script Name:  __main__
x00 x00 x00 x00 x00 x00 x00 x00 x00 [x00]

約10分5秒ごとにByte6がインクリメント

2019-04-13 20:21:51  Script Name:  __main__
xff x86 x02 x24 x40 x00 x01 x00 x13 [x13]
2019-04-13 20:31:55  Script Name:  __main__
xff x86 x02 x33 x40 x00 x02 x00 x03 [x03]
2019-04-13 20:42:00  Script Name:  __main__
xff x86 x02 x30 x40 x00 x03 x00 x05 [x05]
2019-04-13 20:52:05  Script Name:  __main__
xff x86 x02 x25 x40 x00 x04 x00 x0f [x0f]

起動から24時間ごとに Automatic Baseline Correction が行われて Byte7がインクリメント、その後 Byte6がリセット

2019-04-14 20:03:25  Script Name:  __main__
xff x86 x02 x43 x40 x00 x8e x00 x67 [x67]
2019-04-14 20:13:30  Script Name:  __main__
xff x86 x02 x38 x40 x00 x8f x01 x70 [x70]
2019-04-14 20:23:35  Script Name:  __main__
xff x86 x02 x30 x40 x00 x00 x01 x07 [x07]
2019-04-14 20:33:40  Script Name:  __main__
xff x86 x02 x3c x40 x00 x01 x01 xfa [xfa]
2019-04-14 20:43:45  Script Name:  __main__
xff x86 x02 x2b x40 x00 x02 x01 x0a [x0a]
23
23
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
23
23