FrontPage

2020-7-12

室内のCO2濃度が見たい・続続

 

 「室内のCO2濃度が見たい」、「室内のCO2濃度が見たい・続」の続き。
 室内のCO2濃度をLametric Timeで表示できるようになったが、自己満足ついでに値の推移をグラフで見たくなった。
 昨日掲載したスクリプト(room.py)は、cronで5分おきに実行しているのだが、ログとして、室温、湿度、CO2濃度をroom.logへ出力している。

*/5 * * * * cd /home/pi/lametric/;./room.py >> /home/pi/lametric/room.log 2> /dev/null

 room.logの内容は以下のような感じ。

date,temperature,humidity,co2
2020-07-11 13:50,27,68,513
2020-07-11 13:55,27,68,519
2020-07-11 14:00,27,68,507
2020-07-11 14:05,27,68,508
2020-07-11 14:10,27,68,505

 これをグラフ化してみた。

#!/usr/bin/env python3
import pandas as pd
import matplotlib.pyplot as plt
import re
import pprint

df = pd.read_csv('room.log', index_col='date')

period = int(-1 * (60 / 5) * 24 * 1)
ltst = df[period:].interpolate()
data1 = ltst.loc[:, 'temperature']
data2 = ltst.loc[:, 'humidity']
data3 = ltst.loc[:, 'co2']

xt = []
xl = []
idx = ltst.index.values.tolist()
for i in idx:
    if '00:00' in i:
        xt.append(i)
        xl.append(re.search(r'(\d\d-\d\d) ', i).group())
    elif '12:00' in i:
        xt.append(i)
        xl.append('')
    else:
        xt.append('')
        xl.append('')

plt.style.use('seaborn-darkgrid')

fig, [ax1, ax2, ax3] = plt.subplots(3, 1, sharex='col')
fig.set_figwidth(12.8)
fig.set_figheight(9.6)

ax1.plot(data1, color='indianred')
ax1.set_ylabel('temperature')

ax2.plot(data2, color='royalblue')
ax2.set_ylabel('humidity')

ax3.plot(data3, color='seagreen')
ax3.set_ylabel('co2')
ax3.set(xticks=xt, xticklabels=xl)

plt.tight_layout()
plt.savefig('graph.png')

 以下ができたグラフ(graph.png)。一日分のグラフで、9行目で変数periodの値を決めている、右辺の最後の数字が日数を表している。

 12時過ぎのCO2濃度の急上昇は、昼飯でコンロに火を付けたためと思われる。換気したら値が一気に下がり、一緒に温度、湿度も急降下していることが見て取れる。
 だからなに、って話ですが。
 

参考


2020-7-11

室内のCO2濃度が見たい・続

 

 先日書いた「室内のCO2濃度が見たい」の続き。
 2020/06/20にBanggood.comに発注したCO2濃度センサー「MH-Z19」だが、

 昨日(2020/07/10)届いた。発注から到着まで20日間かかったことになる(納期は10日から30日となっていた)。
 なお、届いたのは「MH-Z19B」だった。

 早速、こちらのページを参考に、もともと近所のアメダスの情報やNature Remoの内蔵センサーの値をLametric Timeへ表示するために使っていたRaspberry Pi 3Bに接続してみた。金属製の台の上に置いてあるため、センサーの下にダンボールを貼り付けてある。

mh_z19b.jpg

 センサーの値も問題なく取得できたので、これをLametric Timeに表示してみた。

#!/usr/local/bin/python3
import requests
import json
import datetime
import subprocess

url = 'https://api.nature.global/1/devices'

headers =  {
    'contentType': 'application/json',
    'Authorization': 'Bearer xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
}

res = requests.get(url, headers=headers)
data = res.json()

hum = str(data[0]['newest_events']['hu']['val'])
temp = str(round(data[0]['newest_events']['te']['val'], 1))

mh = subprocess.check_output(['sudo', 'python3', '-m', 'mh_z19']).decode('utf-8')
mh = json.loads(mh)
co2 = str(mh['co2'])

print(f"{datetime.datetime.today().strftime('%Y-%m-%d %H:%M')},{temp},{hum},{co2}")

hum = hum + '%'
temp = temp + '°C'
co2 = co2 + 'ppm'

disp = {
    'frames': [
        {
            'index' : 0,
            'text'  : temp,
            'icon'  : '12464'
        },
        {
            'index' : 1,
            'text'  : hum,
            'icon'  : '12184'
        },
        {
            'index' : 2,
            'text'  : co2,
            'icon'  : '32936'
        }
    ]
}

disp = json.dumps(disp)

headers = {
    'X-Access-Token': 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy',
    'Cache-Control': 'no-cache',
    'Accept': 'application/json'
}

url = "https://developer.lametric.com/api/V1/dev/widget/update/com.lametric.zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz/1"

res = requests.post(url, disp, headers=headers)

 上記のスクリプトをcronで5分おきに実行し、表示する情報を更新している。

 表示は順に、時刻 -> 日付 -> 近所のアメダスの気温 -> 天気と降水量 -> 風速 -> 室温 -> 湿度 -> CO2濃度 となっている(アメダスの情報は別のスクリプトで更新している)。

 まあ、自己満足以外の何物でもないが、満足満足。
 

参考


2020-7-5

Googleカレンダーに阪神とプロ野球の日程をインポートするためのスクレイピング・2020年版

 

calendar.png

 先月(2020/06/19)とうとうプロ野球が開幕した。阪神ファンとしては、2020/07/05現在、早くもシーズン終わったんじゃねえかとも思える状況だが、それはそれとして、昨年以下のようなスクリプトを紹介していた。

 早速今年の日程をGoogleカレンダーにインポートしようとしてみたのだが、これが素直に動いてくれない。
 以下に動かすまでにやったことを示す。

阪神タイガースの日程

 まずはスクリプトの修正。

  • オリックスが「bs」だったのが「b」に変わってたのに対応。
  • 年を「2020」に、日にちを今年の変則日程に応じて変更した。
  • いちいち「JERAセ・リーグ公式戦」と表示されるのがうるさいので消した。
#!/usr/bin/python3
#coding: utf-8

#scrapingtigers.py

import re
import datetime
import urllib.request
import requests
import pprint
from bs4 import BeautifulSoup

data = {}

year = '2020'

team = {
    't':'阪神',
    's':'ヤクルト',
    'd':'中日',
    'h':'ソフトバンク',
    'e':'楽天',
    'f':'日本ハム',
    'l':'西武',
    'db':'DeNA',
    'm':'ロッテ',
    'b':'オリックス',
    'g':'巨人',
    'c':'広島',
}

head = "Subject, Start Date, Start Time, End Date, End Time, Description, Location"
print(head)

#month_days = {'03':'31', '04':'30', '05':'31', '06':'30', '07':'31', '08':'31', '09':'30'}
month_days = {'06':'30', '07':'31', '08':'31', '09':'30', '10':'31', '11':'30'}

for month in month_days.keys():
    data.setdefault(month, {})
    for day in range(int(month_days[month])):
        data[month].setdefault(day + 1, {})
        data[month][day + 1].setdefault('date', year + '/' + month + '/' + ('0' + str(day + 1))[-2:])

for month in month_days.keys():
    html = requests.get("https://m.hanshintigers.jp/game/schedule/" + year + "/" + month + ".html")
    soup = BeautifulSoup(html.text, features="lxml")
    day = 1
    for tag in soup.select('li.box_right.gameinfo'):
        text = re.sub(' +', '', tag.text)
        info = text.split("\n")
        if len(info) > 3:
            if info[1] == '\xa0' or re.match('JERAセ・リーグ公式戦', info[1]):
                info[1] = ''
            data[month][day].setdefault('gameinfo', info[1])
            data[month][day].setdefault('start', info[2])
            data[month][day].setdefault('stadium', info[3])
            if re.match('オールスターゲーム', info[2]):
                data[month][day]['gameinfo'] = info[2]
                data[month][day]['start'] = '18:00'

        text = str(tag.div)
        if text:
            m = re.match(r'^.*"nologo">(\w+)<.*$', text, flags=(re.MULTILINE|re.DOTALL))
            if m:
                gameinfo = m.group(1)
                data[month][day].setdefault('gameinfo', gameinfo)
            m = re.match(r'^.*"logo_left (\w+)">.*$', text, flags=(re.MULTILINE|re.DOTALL))
            if m:
                team1 = m.group(1)
                data[month][day].setdefault('team1', team[team1])
            m = re.match(r'^.*"logo_right (\w+)">.*$', text, flags=(re.MULTILINE|re.DOTALL))
            if m:
                team2 = m.group(1)
                data[month][day].setdefault('team2', team[team2])

        day += 1

for month in month_days.keys():
    for day in data[month].keys():
        if data[month][day].get('start'):
            m = re.match(r'(\d+):(\d+)', data[month][day]['start'])
            if m:
                sthr = m.group(1)
                stmn = m.group(2)
                start = datetime.datetime(int(year), int(month), int(day), int(sthr), int(stmn), 0)
                delta = datetime.timedelta(hours=4)
                end = start + delta
                sttm = start.strftime("%H:%M:%S")
                entm = end.strftime("%H:%M:%S")
                summary = ''
                if data[month][day]['gameinfo']:
                    summary = data[month][day]['gameinfo'] + " "
                if not re.match('オールスターゲーム', data[month][day]['gameinfo']):
                    summary += data[month][day]['team1'] + "対" + data[month][day]['team2']
                #head = "Subject, Start Date, Start Time, End Date, End Time, Description, Location"
                print(f"{summary}, {data[month][day]['date']}, {sttm}, {data[month][day]['date']}, {entm}, {summary}, {data[month][day]['stadium']}")

 これぐらいの修正で動きそうなものだが(環境はWindow10のWSL(Ubuntu20.04))、

SSL routines:tls12_check_peer_sigalg:wrong signature type

みたいなエラーを吐いて止まってしまう。
 原因は、Ubuntu20.04にデフォルトで入ってるOpenSSLのバージョンが古いため。1.1.1fが入ってるんだが、これを1.1.1gへ上げれば、無事動くようになる。バージョンアップはaptではダメで、ソースコードからコンパイルする必要があるようなので(少なくとも私はそうした)ググってきちんと調べてやってください。
 後は出力結果をcsvファイルへ吐き出して、それをGoogleカレンダーへインポートすればOK。

プロ野球の日程

 こちらも変なエラーに悩まされたが、環境のアップデートではなく、スクリプトの修正で事足りた。

  • 年を「2020」に、日にちを今年の変則日程に応じて変更した。
  • SSL認証でエラーが出ないよう、対応を追記。
#!/usr/bin/python3
#coding: utf-8

#scrapingnpb2.py

import sys
import re
import datetime
import pandas as pd
import ssl

ssl._create_default_https_context = ssl._create_unverified_context

print("Subject, Start Date, Start Time, End Date, End Time, Description, Location")

year = '2020'
#months = ['03', '04', '05', '06', '07', '08', '09']
months = ['06', '07', '08', '09', '10', '11']

# 0,     1,              2,                 3,          4,   5
#(0, '3/29(金)', 'DeNA -  中日', '横\u3000浜  18:30', nan, nan)

for month in months:
    url = "http://npb.jp/games/" + year + "/schedule_" + month + "_detail.html"
    tb = pd.io.html.read_html(url)
    for row in tb[0].itertuples(name=None):
        card = ''
        md = re.sub(r'(.*)', '', row[1])
        ymd = year + '/' + md
        sttm = ''
        entm = ''
        place = ''
        if row[2] == row[2]:
            card = re.sub(' -  ', '対', row[2])
        if row[3] == row[3]:
            place_time = row[3].split('  ')
            if len(place_time) > 1:
                (sthr, stmn) = place_time[1].split(':')
                (mon, day) = md.split('/')
                start = datetime.datetime(int(year), int(mon), int(day), int(sthr), int(stmn), 0)
                delta = datetime.timedelta(minutes=200)
                end = start + delta
                sttm = start.strftime("%H:%M:%S")
                entm = end.strftime("%H:%M:%S")
                place = re.sub(r'\s+', '', place_time[0])
            else:
                sttm = '18:00:00'
                entm = '21:20:00'
                place = place_time[0]

        if len(sys.argv) > 1:
            m = re.search(sys.argv[1], card)
            if m:
                print(f"{card}, {ymd}, {sttm}, {ymd}, {entm}, {card}, {place}")
        elif card != '':
            print(f"{card}, {ymd}, {sttm}, {ymd}, {entm}, {card}, {place}")

 当初は以下のようなエラーに悩まされた。

urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1108)>

 去年との違いは、NPBのサイトが「https」になっていたこと。
 でも、pandasのサイトによると、以下のようにpandas.read_htmlは(というかlxmlは)httpsには対応していない。

pandas.read_html(io, match='.+', flavor=None, header=None, index_col=None, skiprows=None, attrs=None, parse_dates=False, thousands=',', encoding=None, decimal='.', converters=None, na_values=None, keep_default_na=True, displayed_only=True)
Read HTML tables into a list of DataFrame objects.
Parameters:io:str, path object or file-like object
A URL, a file-like object, or a raw string containing HTML. Note that lxml only accepts the http, ftp and file url protocols. If you have a URL that starts with 'https' you might try removing the 's'.

 なので、スクリプト上「http」にアクセスしているのはそれはそれで正しいはずなのだが、なぜかSSL認証エラーになってしまう(サイト側でhttpへのアクセスをhttpsへリダイレクトしてるためか?)。
 で、しかたがないので、SSL認証エラーを無視するようにした次第。
 後はこれまた出力結果をcsvファイルへ吐き出して、それをGoogleカレンダーへインポートすればOK。


2020-6-29

鉄道遅延情報の取得先の変更

 

 毎朝、その日の予定や天気予報などをGoogle Homeにしゃべらせている。
 通勤に使う鉄道の運行遅延情報もしゃべらせているのだが、ここ数日(2020/06/25以降)、なぜかしゃべらなくなってしまった。
 原因は、以下の「鉄道遅延情報のjson」のAPIが止まってしまったから。

 提供元のサイトには何のアナウンスもないが、元々善意で運営されていたものであり、「予告なく中断することがあります」との記載もあることから、まあ、仕方がない。
 今までありがとうございました。

 もしかしたら、しばらく待っていれば復活するのかもしれないが、いい機会なので、止まってしまったAPIのネタ元である「Tetdudo.com(鉄道コム)」の「運行情報サイト更新状況」のページのフィード(Atom1.0形式)

から遅延情報を取得するよう、変更することにした。

 以下にGAS(Google Apps Script)の該当の関数を示す。

修正前

function trainDelay() {
  var url = "https://tetsudo.rti-giken.jp/free/delay.json";
  var response = UrlFetchApp.fetch(url); 
  var json = JSON.parse(response.getContentText()); 

  var text = "";
  for (var i in json) {
    if (json[i].name === "水郡線" || json[i].name === "七尾線" || json[i].name === "瀬戸大橋線") {
      text += json[i].name + "、";
    }
  }
  
  if (!text) {
    text = "鉄道の運行遅延情報はありません。";
  } else {
    text += "が遅延しています。";
  }

  return text;
}

修正後

function trainDelay() {
  var url = 'http://api.tetsudo.com/traffic/atom.xml';
  var response = UrlFetchApp.fetch(url);
  var xml = XmlService.parse(response.getContentText());
  var ns = XmlService.getNamespace('', 'http://www.w3.org/2005/Atom');
  var items = xml.getRootElement().getChildren('entry', ns);

  var check = ['水郡線', '七尾線', '瀬戸大橋線'];

  var text = "";
  for(var i in items) {
    title = items[i].getChild('title', ns).getText();
    for (var j in check) {
      reg = new RegExp(check[j]);
      if (title.match(reg)) {
        text += check[j] + "、";
      }
    }
  }

  if (!text) {
    text = "鉄道の運行遅延情報はありません。";
  } else {
    text += "が遅延しています。";
  }

  return text;
}

 悩んだのは、なぜかXMLがうまくパースできなかったこと。ググってみつけたこちらのページの記載から、いちいち名前空間を指定する必要があることを知る。めんどくせえ。


2020-6-20

室内のCO2濃度が見たい

 

注意

 以下の記事は技術的にはほとんど役に立たない。馬鹿な不注意が招く間抜けな徒労に充ちた失敗談である。
 

LaMetric Time

 数年前に買ったLaMetric Timeに、いろんな情報を表示して楽しんでいる。

 正直、実用性には乏しい気もするが、まあ、賑やかしということで。

 表示しているのは、順に、

  • 時刻
  • 日付 曜日
  • 近所のアメダスの情報
    • 気温
    • 天気(アイコンで晴/曇/雨を切り換え)と降水量
    • 風速
  • Nature Remoの内蔵センサーの値
    • 室温
    • 湿度

 最近部屋に籠もる機会が増え、以前にも増して室内環境を気にするようになったのだが、これまた何年も前に買ったWithingsの体重計WS-50、

こいつにCO2濃度センサーが内蔵されていることを思い出した。手持ちのWithingsの機器の情報が閲覧できるサイトからも、以下のようにCO2濃度のグラフが確認できる。

 
 このCO2濃度をLaMetric Timeに表示したい!
 
 室内のCO2濃度が1000ppmを越えると、人は眠気を覚え作業効率が大きく下がるという。
 グラフを見るとCO2濃度は30分に一度測定されているようなので、これをLaMetric Timeに常時表示し、さらに1000ppmを越えたら赤く表示するなどして換気をうながす。そういう仕組みを構築したいと考えた。
 

Withings API(OAUTH 2.0)

 ググってみると、Withingsが機器の情報を取れるAPIを提供していることがわかった。

 使い方は、以下のサイトで詳細に説明されている。

 上記のページの手順どおり進めて、WS-50の情報を取ることに成功した。
 しかし、確かに体重なんかの情報は取れるんだが、CO2濃度の取り方がわからない。
 事ここに至りようやく気づいたのだが、取れる情報の中にCO2濃度がないのである! Withings APIの解説サイトのこちらのページに掲げられている、取れる情報一覧の表をよくよく見てみると、CO2濃度の項目はない。
 なお、どのみちCO2濃度の情報が取れないので、私にとってはどうでもいいのだが、このAPIを使うにはアクセストークンが必要で、これが30分で期限が切れてしまい、そのたびに再発行する必要がある。これが何気に面倒くさくてかなり使いにくい。
 

Withings WS-50 Scale Syncer - Temperature & CO2

 あきらめきれずになおもググっていると、まさに私の用途にばっちりと思えるページを見つけた。

 上記のページでは、Withings WS-50 Scale Syncer - Temperature & CO2という、Withingsの非公式なAPIを使ってWS-50の気温とCO2濃度のセンサーの値を取り、DomoticzというOSSのホームオートメーションシステムと連携させるツールをそのまま流用しているのだが、こちとらWS-50のデータさえ取れればそれでいいので、このツールを書き換えることにした。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from datetime import datetime
import sys
import time
import hashlib
import requests

TMPID = 12
CO2ID = 35

NOW = int(time.time())
PDAY = NOW - (60 * 60 * 24)

HEADER = {'user-agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36'}

URL_BASE = "https://scalews.withings.net/cgi-bin"
URL_AUTH = URL_BASE + "/auth?action=login&appliver=3000201&apppfm=android&appname=wiscaleNG&callctx=foreground"
URL_ASSO = URL_BASE + "/association?action=getbyaccountid&enrich=t&appliver=3000201&apppfm=android&appname=wiscaleNG&callctx=foreground&sessionid="
URL_USAGE = "https://goo.gl/z6NNlH"


def authenticate_withings(username, password):
    global pem
    try:
        import certifi
        pem = certifi.old_where()
    except Exception:
        pem = True
    requests.head(URL_USAGE, timeout=3, headers=HEADER, allow_redirects=True, verify=pem)
    payload = {'email': username, 'hash': hashlib.md5(password.encode('utf-8')).hexdigest(), 'duration': '900'}
    response = requests.post(URL_AUTH, data=payload)
    iddata = response.json()
    sessionkey = iddata['body']['sessionid']
    response = requests.get(URL_ASSO + sessionkey)
    iddata = response.json()
    deviceid = iddata['body']['associations'][0]['deviceid']
    return deviceid, sessionkey


def download_data(deviceid, sessionkey, mtype, lastdate):
    payload = '/v2/measure?action=getmeashf&deviceid=' + str(deviceid) + '&meastype=' + str(mtype) + '&startdate=' + str(lastdate) + '&enddate=' + str(NOW) + \
        '&appliver=3000201&apppfm=android&appname=wiscaleNG&callctx=foreground&sessionid=' + str(sessionkey)
    try:
        response = requests.get(URL_BASE + payload)
    except Exception:
        sys.exit("[-] Data download failed, exiting" + "\n")
    dataset = response.json()
    return dataset


def main():
    username = 'mail@address'
    password = 'password'

    deviceid, sessionkey = authenticate_withings(username, password)
    co2data = download_data(deviceid, sessionkey, CO2ID, PDAY)

    for item in sorted(co2data['body']['series'][0]['data'], key=lambda x:x['date'], reverse=True):
        dt = datetime.fromtimestamp(item['date'])
        print(f"date:{dt}, co2:{item['value']}")

    return

if __name__ == "__main__":
    main()

 上記のスクリプトではデバッグ用に、測定日時とその時のCO2濃度を一行に、一日分を新しい順に表示している。

date:2020-06-20 13:30:13, co2:509
date:2020-06-20 13:00:12, co2:519
date:2020-06-20 12:30:13, co2:506
date:2020-06-20 12:00:12, co2:497
date:2020-06-20 11:30:13, co2:509
……

 当初はこの測定日時を、取れたデータそのままのunixtimeで表示していたため、なかなか気づかなかったのだが、このスクリプトを実行しても、最新のデータとして取れるのは、実行時の何時間も前のものだったのである。
 これは一体なぜなのか?
 事ここに至りようやく気づいたのだが、前述の「Withings WS-50を使ってCO2濃度をCloudWatchメトリクスに保存する」のページに、それも冒頭部分にしっかりと、以下のように書いてある。

ただし、WS-50の場合センサ情報の送信が以下の2パターンしかないため、リアルタイムでCO2濃度を取得することはできない。
・1日一回どこかのタイミングで環境センサ情報(CO2濃度、温度)を送信
・体重を測ったタイミングで環境センサ情報も送信
なお、CO2濃度情報は30分おきに記録されている。

 つまり、CO2濃度の測定自体は30分毎に行われているのだが、それがウェブ上にアップロードされるのは体重測定時か一日一度のどこかのタイミングのみ。いくら情報を取りに行っても、取れる情報が更新されるのは一日一度だけ。上記スクリプトで表示されるのは、前回の更新時の情報が最新になるわけで、それがスクリプト実行時の何時間も前だったのである。

 結局、WS-50内蔵のCO2濃度センサーは、私が想定した用途では使えないということがわかった。
 

MH-Z19

 それでもあきらめきれない私がどうしたかというと、Banggood.comで以下のCO2濃度センサーMH-Z19を発注してしまった。

 なぜAmazonではなくBanggoodで買ったかというと、Banggoodの方が値段も安く納期も短かったから。それでも納期は10日から30日後。値段は送料込みで2529円。
 CO2濃度センサーが届く頃まで、この情熱が失われていなければ良いが。



これより前の5日分