電源周波数観測のサーバプログラム

電源周波数の変動を測ってみたという記事のコメント欄で

周波数観測サイトのWEB側のプログラムの解説記事を作っていただけないでしょうか??

という要望をいただきましたので掲載します。人に見せるために書いたコードではないのでいきあたりばったりなコードで汚いです。なお、Webプログラミングについては全くの素人です。なので、ツッコミどころ多数だと思いますが、ご容赦をw。
ソースをみるとわかるとおり、ごく簡単なロジックです。今回は折れ線グラフですが、chart.jsでは棒グラフやその他のグラフなどを簡単に出力することができますので、Raspberry Piなどを使って得たセンサーデータなどを手間をかけずにWebブラウザで見れるようにする叩き台にはなると思います。
(手間かけたくないんだったら、Ambientとかに投げちゃえばいいじゃん、という話はありますが)

はじめに

ここでは、Webサーバの構築についてはなんの工夫もないので記載しません。単に lighttpd を apt でインストールしただけです。nginx でも良かったのですが、lighttpd を使ってみたかったので、lighttpdを使っています。Webサーバプログラムの機能は何も使っていませんので、何を使っても大丈夫だと思います。
あと、記述(実際の運用も)ではダイナミックDNSサービスを使用していますが、これはもともと長期運用する予定ではないからです。これは今でも同じで、そのうちに停止またはURLを変更するつもりです。

基本的な考え方

プログラムの基本的な考え方は、

  • Pythonでシリアルポートからデータを受信する。ちなみに、現在データを取得しているのはATtiny2313の方ではなくて、こちらの記事の秋月のAE-ATmega328ボードです。
  • 受信したデータを受信時刻と共にリストに保持する
  • データ数が所定の数量より多い場合には、一番古いデータを捨てる。
  • Webページのソースコードに合わせて1ページ分のHTMLファイルを出力する。
    グラフ化は chart.js のサンプルソースコードに合わせてデータを出力しています。Javascriptは全く知識がないので、馬鹿正直にサンプルソースに合わせて出力しています。実際には、もっと簡単になるはずです。
  • 長期間データはWebページが重くなるので、データを15秒単位で平均化したものに間引いて同じことをする。

というだけです。なので、長時間見ていると、ファイル更新の瞬間にJavascriptでのリロードがかかって、ファイルがなくて更新が止まる場合があるわけです。対策はいろいろあると思いますが、めんどくさいのでやってません^^;

準備

python3でシリアルポートを扱うので、以下の操作を行っておきます。(lighttpdのインストールも行います)

$ sudo apt install lighttpd python3-pip
$ sudo python3 -m pip install pyserial

ソースコード

ソースコードは下記ですが、コメントのないところの補足は下記です。

  • 初めの方でシリアルポートを開く宣言があります。シリアルポートを使う場合には、pyserial モジュールをインストールしておく必要があります。シリアルポートの速度はハードウェアに合わせてください。こちらの記事の場合には速度は38400です。(下記ソースは9600になっています)
  • header と footer という変数にヒアドキュメント形式でデータの前後のHTML+Javascriptのソースコードが書いてあります。長時間のページは後から追加したので、header_long と footer_long という変数になっています。それぞれの最後に、Javascriptでリロードするよう記述してあります。
  • prepare は準備中に表示するソースコードです。
  • 実際の受信とHTMLの生成は無限ループしている recieve() という関数だけです。短時間のページはデータを受信する度(つまり約1秒に1回)、長時間のページは15回データを受信する度にページ全体を生成しています。
# -*- coding:utf-8 -*-
#
import os.path,sys
import serial
import time

hist = []        # データを保持する変数
hist_long = []   # データを保持する変数
hlen = 600       # データを保持する数
hlen_long = 2880 # データを保持する数

port = serial.Serial('/dev/ttyUSB0', baudrate=9600, parity=serial.PARITY_NONE)

header = """<!DOCTYPE HTML>
<html lang="ja">

<head>
  <meta charset="utf-8">
 <title>電源周波数変動</title>
</head>

<body>
  <h1>過去10分間の電源周波数変動</h1>
  10秒ごとにReloadして更新します。時々、元データの更新のタイミングと重なってページの更新が止まるときがあります。
  時刻はサーバプログラムがデータを受信した時刻です。サーバの負荷が重くて受信データがキューに貯まると、あとでまとめて受信した時刻に複数のデータが表示されることがあります。
  <A HREF="long.html">長時間のデータはこちら</A>
  <HR>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.js"></script>
    <canvas id="Chart1" width="auto" height="auto"></canvas>
    <script>
      var context = document.getElementById('Chart1');
      var Chart1 = new Chart(context, {
        type: 'scatter',
          data: {
            datasets: [
              {
                label: '商用電源の周波数' ,
                showLine: true ,
                lineTension: 0 ,
                fill: false ,
                borderColor: "rgba(  0,  0,255,0.5)",
                borderWidth: 1,
                pointRadius: 1,
"""
footer = """
              }]},
          options:{
            scales:{
              xAxes: [{
                gridLines: {
                  color: "rgba(255, 0, 0, 0.2)", 
                  zeroLineColor: "black"    },
                type: 'time',
                time: {
                  unit: 'second',
                  displayFormats: {
                    second: 'H:mm:ss'
                  },
                },
              }],
              yAxes: [{
                ticks :{
                  userCallback: function(tick) {
                    return tick.toString() + 'Hz'
                  }
                },
                gridLines: {
                  color: "rgba(0, 0, 255, 0.2)",
                  zeroLineColor: "black"
                },
              }]
            },
            animation: false
          }
      });
    </script>
    <script>
    // 10秒に一回リロード
      setTimeout("location.reload(true)",10000);
    </script>
</body>
</html>
"""

AVN = 15   # 平均を取る回数
header_long = """<!DOCTYPE HTML>
<html lang="ja">

<head>
  <meta charset="utf-8">
 <title>電源周波数変動(長時間)</title>
</head>

<body>
  <h1>過去12時間の電源周波数変動</h1>
  データの粒度は15秒です。15秒間の平均周波数をグラフ化しています。
  5分ごとにReloadして更新します。時々、元データの更新のタイミングと重なってページの更新が止まるときがあります。
  時刻はサーバプログラムがデータを受信した時刻です。サーバの負荷が重くて受信データがキューに貯まると、あとでまとめて受信した時刻に複数の数のデータが表示されることがあります。
  <A HREF="index.html">短時間のデータはこちら</A>
  <HR>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.js"></script>
    <canvas id="Chart1" width="auto" height="auto"></canvas>
    <script>
      var context = document.getElementById('Chart1');
      var Chart1 = new Chart(context, {
        type: 'scatter',
          data: {
            datasets: [
              {
                label: '商用電源の周波数' ,
                showLine: true ,
                lineTension: 0 ,
                fill: false ,
                borderColor: "rgba(  0,  0,255,0.5)",
                borderWidth: 1,
                pointRadius: 1,
"""
footer_long = """
              }]},
          options:{
            scales:{
              xAxes: [{
                gridLines: {
                  color: "rgba(255, 0, 0, 0.2)", 
                  zeroLineColor: "black"    },
                type: 'time',
                time: {
                  unit: 'minute',
                  displayFormats: {
                    minute: 'H:mm'
                  },
                },
              }],
              yAxes: [{
                ticks :{
                  userCallback: function(tick) {
                    return tick.toString() + 'Hz'
                  }
                },
                gridLines: {
                  color: "rgba(0, 0, 255, 0.2)",
                  zeroLineColor: "black"
                },
              }]
            },
            animation: false
          }
      });
    </script>
    <script>
    // 5分=300秒に一回リロード
      setTimeout("location.reload(true)",300000);
    </script>
</body>
</html>
"""

prepare = """<!DOCTYPE HTML>
<html lang="ja">

<head>
  <meta charset="utf-8">
 <title>電源周波数変動</title>
</head>

<body>
  <h1>準備中です。1分程度で開始します</h1>
  <script>
  // 1分=60秒に一回リロード
    setTimeout("location.reload(true)",60000);
  </script>
</body>
</html>
"""


def recieve():
  port.reset_input_buffer()
  avc = 0    # 平均を取るためのカウンタ
  avd = 0    # 平均を取るための積算変数
  while True:
    try:
      data = float(port.readline().decode().rstrip('\r\n'))
    except(ValueError):
      data = None
      pass
    except:
      print("Unexpected error:",sys.exc_info()[0])
      raise

    # 短期間データ
    # print(time.time(),data)
    if data is not None :
      hist.append((time.time(),data))
    while len(hist) > hlen :
      del(hist[0])
    s  = "  data :["
    for i in hist:
      s += "{{ x: {:.0f} , y: {} }},".format(i[0]*1000,i[1]/1000)
    s = s.rstrip(',')
    s += "]"
    #print(s)
    with open('index.html',mode='w') as f:
      f.write(header + s + footer)

    # 長期間データ
    # print(time.time(),data)
    if data is not None :
      avd += data
      avc += 1
      if avc == AVN :
        hist_long.append((time.time(),avd/AVN))
        avc = 0
        avd = 0
    if avc == 0 :  # 15秒に一回データ生成
      while len(hist_long) > hlen_long :
        del(hist_long[0])
      s  = "  data :["
      for i in hist_long:
        s += "{{ x: {:.0f} , y: {} }},".format(i[0]*1000,i[1]/1000)
      s = s.rstrip(',')
      s += "]"
      #print(s)
      with open('long.html',mode='w') as f:
        f.write(header_long + s + footer_long)

  port.close()

#
if __name__ == "__main__":
  with open('index.html',mode='w') as f:
    f.write(prepare)
  with open('long.html',mode='w') as f:
    f.write(prepare)
  time.sleep(60)
  recieve()

ローカルで見るとかなら、直接Pythonで起動してもいいと思います。

サービスとして起動

実際の運用では電源投入時に起動したいので、そのための設定をしていきます。
先のPythonのコードを/var/www/readdata.py として保存して、オーナーはrootとして実行権限を付けておきます。次に、systemdでサービスとして起動するためのファイルを /etc/system/systemd/readdata.service として作成します。

[Unit]
Description=https://www.power.f5.si/
After=network-online.target
 
[Service]
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/python3 /var/www/readdata.py
User=root
Group=root
 
[Install]
WantedBy=network-online.target

この辺の書き方も適当です。本当はrootで起動しないほうがいいんじゃないかとか、ツッコミどころがたくさんあるかもしれません。その場合は優しくお願いします(笑)

ネットワークが完全に起動してからこのサービスをスタートさせます。ですので、network-online.target を使用するために、

$ sudo systemctl enable systemd-networkd
$ sudo systemctl enable systemd-networkd-wait-online

を実行してから、先のサービスファイルを起動します。

$ sudo systemctl start readdata.service 
$ sudo systemctl status readdata.service 
● readdata.service - https://www.power.f5.si/
   Loaded: loaded (/etc/systemd/system/readdata.service; disabled; vendor preset
   Active: active (running) since Tue 2021-01-12 00:00:07 JST; 3s ago
 Main PID: 740 (python3)
    Tasks: 1 (limit: 387)
   CGroup: /system.slice/readdata.service
           └─740 /usr/bin/python3 /var/www/readdata.py
 
Jan 12 00:00:07 power systemd[1]: Started https://www.power.f5.si/.
$ sudo systemctl enable readdata.service 
Created symlink /etc/systemd/system/multi-user.target.wants/readdata.service → /etc/systemd/system/readdata.service.

これで起動完了です。これで起動時も勝手にPython3スクリプトが動作します。

#むー、WorkingDirectoryにtmpfsでmountしたディレクトリを指定すると起動に失敗する・・・

補足

実際の運用では、overlayfsを有効にしています。これは停電対策だったり、フラッシュメモリ(SDカード)を保護する目的だったり、(他の対策もしているので入り口はWebサーバしかなく、しかも静的コンテンツしかないので大丈夫だと思いますが)万が一書き換えられてしまっても電源OFF/ONで復旧できるようにするためです。(最大の理由は、overlayfsを使ってみたかった、なのですが)
代わりに、メモリが枯渇するとファイルが作れなくなって停止してしまいます。

あと、このサーバのハードウェアはRaspberry Piで、このPythonプログラム(とlighttpd)を動かす目的のためだけに運用していますが、単純にRaspberry Piで公開しているわけではありません。単純にグローバルアドレスを持たせて公開してしまうと、あっという間に攻撃にあってしまいます。
万が一乗っ取られてしまうと、同じネットワーク内の他の機器が二次被害にあったり、見知らぬ無関係のサーバへ攻撃を仕掛ける踏み台に使われてしまったりする危険もありますので、ご注意ください。

後日談

上記の記事は先に作ったATmega328用ですが、あとから作ったATTINY2313用にも立ち上げたところ・・・なんと、systemdで引っかかってしまいます。同じ設定のはずなのに、自動起動ができません。
そのときに参考にさせていただいたのがこちらのサイトです。

“電源周波数観測のサーバプログラム” への5件の返信

  1. はじめまして、こんにちは。
    私も同じようなことやっていまして、うちの記事からいくつかリンク貼らせていただいてます。

    実は何度かコメントの書き込みを試みているのですが、だめだったのでURL無しで書いてみます。「ラジオペンチ 電源周波数測定」 あたりで検索するとたぶん見つかると思います。2月14日の記事がこの関係の最初の記事です。

    1. ラジオペンチさん、こんばんは。お返事遅くなってすみません。
      ラジオペンチさんのブロクはGoogleさんで調べごとをすると検索ででてくることが結構あるので、時々拝見させていただいています。
      ラジオペンチさんも2/13の地震での大きな周波数変動の直接観測をされていたのですね。記事を興味深く拝見させていただきました。自分も電圧も測りたいのですが、どうせならある程度絶対値を測定したいのと、できれば一次側を直接測定したいので今のところ見送っています。

      コメント欄の方はコメントスパムにかつて困ったことがありまして、コメントスパム対策フィルタを有効にしています。(今でも、毎月コメントスパム扱いされているものが数百件あるようです)
      日本語なしのものの削除以外に、どのような挙動をするかはよくわかっていませんが、メールアドレスに見えるものは一般的な技術としてDNSのMXレコードくらいは確認してるのでは?と思います。(裏を返せば、存在するドメインなら大丈夫なのかもしれません)
      で、2/14のものはすでに自動で削除済みで、残っているのは2件、メールアドレスのドメインで引っかかってそうなのがありました。

  2. とものさんの電源周波数変動サイトですが、
    グラフだけをリアルタイム更新って出来ますでしょうか…??

    1. 技術的にはやる方法はあると思いますし、やりたいとは思っています。
      例えば、epoch.js とかを使ってリアルタイムに綺麗に更新したいところですが、自分の実力が追いついてません。描画だけではなく、データを非同期に転送し続ける方法なども勉強しないと、というところですね。

もも へ返信する コメントをキャンセル

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)