t-hom’s diary

主にVBAネタを扱っているブログです。

無線LAN-SDカードとコンデジでブログ用写真を快適に…と思ったらハマった件

電子工作を初めてから写真を撮る頻度が上がったのだが、スマホで写真を撮るのを面倒に感じていた。
OneDriveで自動的にPCと同期する設定にしているが、あまり同期の頻度が良くないし逐一クラウドを経由するのも好みではない。

それで無線LAN付きのカメラで自動同期できないかと探していたんだけど、そうした機能を謳っている手ごろなカメラが見つからずに困っていたところ、無線LAN付きのSDカードの存在を知った。
東芝のFlashAirという製品は、サードパーティー製のSnowyというツールを使うことで自動でPCに写真を取り込めるらしい。

これを使えば、手持ちのコンパクトデジカメ(Nikon COOLPIX P300)に挿して無線でファイルを取得できる。

Amazonで中古(16GBが6,800円)を見つけたので購入してみた。
f:id:t-hom:20210315163908p:plain

※実はモノが届くまで中古だということに気づいてなかった。最近Googleの言語設定を英語にしたせいか、Amazonまで英語表示になってるので説明をちゃんと読んでなかった為である。

今朝届いたので早速PCから設定しようとしたんだけれど、、
f:id:t-hom:20210315163317p:plain
f:id:t-hom:20210315163600p:plain

は?

いやいや、落ち着け。多分あれだ、2013年の製品だから旧製品カテゴリーに移動させたとかそういうのだろう。

…ない。

ググってみると製品サポートがキオクシアという会社に移っているとのことで、そちらで探してみたところ、
f:id:t-hom:20210315165152p:plain

は?

まじか。買ったばっかりなのに?

一応APモード(FlashAirが無線LAN親機になるモード)で接続できなくはないけど、逐一PCの無線切り替えて繋ぎに行くなんてのはナンセンス。STAモード(FlashAirが無線子機になるモード)でメインのLANからさくっとアクセスして欲しい。

小一時間ほど凹みながらググっていたところ、どうやらCONFIGファイルをテキストエディタでいじれば設定できるらしい。

参考サイト
https://flashair-developers.github.io/website/docs/api/config.html

私の設定
f:id:t-hom:20210315171348p:plain

これでとりあえず接続できた。

次に以下よりSnowyをDLする。
emoacht.github.io

Zipを展開するとSnowyImageCopy.exeが入っているので実行。
SnowyTool.exe

Win10の場合はネットから拾ってきたプログラムということで最初に警告が出るが、詳細設定から実行するを選択することで使用可能である。(あくまで自己責任で)

SnowyTool.exeという実行ファイルもあるので何か初期設定でもするのかなと思って起動してみたけど、よく分からなかったので無視して良さげ。

SnowyImageCopy.exeの使用方法は他のサイトに譲るとして、少し戸惑った点だけ説明しておく。
まず接続先や保存先などは右上のオプションボタンから設定する。少し変わった開き方をするけれど慣れの問題なのでそこは別に問題ない。
FlashAirアドレスを指定する欄は、http://のあとにAPPNAMEで指定した名前を入れるのだが、その後スラッシュで続けて実際に写真が保存されるパスを指定しなければならない。
ルートフォルダーから自動で探してきてくれたりはしないのでそこが少し面倒である。

パスが不明なときはブラウザからhttp://のあとにAPPNAMEを指定して開くとフォルダーをたどれるので、実際に写真のあるフォルダーが開いたらブラウザのアドレス欄からそのままコピーすれば良い。
f:id:t-hom:20210315173150p:plain

自動チェック感覚は分指定かと思いきや秒指定だった。デフォルトは30秒であるが、私は10秒に設定した。
常に自動チェックをかけるというよりは、カメラを使う時だけアプリを起動し、自動チェックをオンにした状態で撮影する感じ。
撮った傍からPCにたまっていく。それと、自動削除も設定したのでPCへ取り込みが済んだらSDカードからはデータが自動削除されていく。
f:id:t-hom:20210315174647p:plain

これですこぶる快適な環境が整った。

ひとつ難点を挙げると、電池の消耗が早いことである。
まぁこればかりは仕方がない。色々試していたせいもあって早々に電池が切れてしまったけれど、普通に記事執筆のために使う分には問題ないと思う。

さて、今回は公式サポート終了品を購入してしまったために色々と設定で苦労した。
これから購入される場合は以下の製品がおススメである。

上記はSnowyのサイトで動作検証済と書かれたSDカードのうち一番新しいもので、キオクシアによるツールのサポートも続いているようだ。

ラズパイ本番機でカロリー記録&運動時間記録システムの運用開始

しばらくRaspberry Piの開発機側で運用していたカロリー記録システムと合わせて、最近完成した運動時間記録システムをラズパイの本番機にデプロイした。

動作イメージ

以下の5つのプログラムが動作中。
f:id:t-hom:20210228130148p:plain

体重記録だけはデーモン化してあるので表には出てない。

グラフ3種は外部から通信が入った時にそれらに応じて自動で切り替わるようになっていて、体重計に乗ると体重グラフが、M5 Stackで運動量を記録すると運動量グラフが、M5 Stackでカロリー摂取量0キロカロリーを送付するとカロリーグラフが最前面に表示される。

グラフ切替の仕組み

それぞれのグラフに対する入力があった際に単にプロセスをKillして再度起動させているために最前面に上がってくるだけ。
リアルタイムに更新させたかったけど今のところそんなスキルはない。

カロリーだけ0キロを送らなければいけない仕様にしたのもその関係で、摂取カロリーは連続で入力するのでそのたびグラフの再起動は避けたいからだ。

グラフはtkinterに埋め込んでいて、pythonプログラムのプロセス名を変更するライブラリを使用しているので他のプログラムから用意にプロセスを特定してKillできる。
プロセス間通信とかでKillしなくても更新できる方法を模索したこともあったけど一旦挫折した。

グラフをtkinterに埋め込む部分のコード

変更点に関係するところだけ掲載。
※変更前コードはブログ紹介用にコメントで残したが実際は綺麗に消えてる。

import setproctitle as sp
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, date, timedelta

#tkinterとFigureCanvasTkAggをインポート
import tkinter as tk
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg


#データ加工関係は省略。以前の記事をご参照ください。

#前回はfigやaxを作らずに直接pltにbarを出力していた。
fig, ax = plt.subplots()
 
#plt.title("Calorie Record", fontsize = 22)

#plt.xlabel("Date", fontsize = 22)
ax.set_xlabel('Date')

#plt.ylabel("Calorie", fontsize = 22)
ax.set_ylabel('Calorie')

#plt.grid(True)\
ax.grid(True)
 

#plt.bar(record_date, maximum-minimum, width=0.25, bottom = minimum, tick_label = record_date, align="center", label="Guideline", color = "#98fb98", edgecolor="#008000", lw=0, hatch="/////")
ax.bar(record_date, maximum-minimum, width=0.25, bottom = minimum, tick_label = record_date, align="center", label="Guideline", color = "#98fb98", edgecolor="#008000", lw=0, hatch="/////")

#plt.bar(record_date, breakfast, width=0.2, tick_label = record_date, align="center", label="Breakfast", color = "#f3d394")
ax.bar(record_date, breakfast, width=0.2, tick_label = record_date, align="center", label="Breakfast", color = "#f3d394")

#plt.bar(record_date, lunch, width=0.2, bottom = breakfast, tick_label = record_date, align="center", label="Lunch", color = "#45938b")
ax.bar(record_date, lunch, width=0.2, bottom = breakfast, tick_label = record_date, align="center", label="Lunch", color = "#45938b")

#plt.bar(record_date, dinner, width=0.2, bottom = breakfast+lunch, tick_label = record_date, align="center", label="Dinner", color = "#003a34")
ax.bar(record_date, dinner, width=0.2, bottom = breakfast+lunch, tick_label = record_date, align="center", label="Dinner", color = "#003a34")

#plt.show()

def _destroyWindow():
    root.quit()
    root.destroy()

root = tk.Tk()
root.title(u"Calorie Intake Graph")
root.configure(bg='black')
root.withdraw()
root.protocol('WM_DELETE_WINDOW', _destroyWindow)

#ここでfigをcanvasに入れてルートウインドウの配下にする。
canvas = FigureCanvasTkAgg(fig, master=root)
canvas.draw()
canvas.get_tk_widget().pack(fill=tk.BOTH,expand=1)

root.update()
root.attributes("-zoomed", "1")
root.deiconify()
root.mainloop()

matplotlibってサイトによって書き方がまちまちでうまく行く方法を探すのに苦労した。
棒グラフをpltじゃなくてfigを作ってaxにプロットするサンプルを探し回ってなかなか思うようなコードが見つからずに苦労したんだけど結局たどり着いたのは本家matplotlibのサイト。

ここにそのままやりたいことが載ってた。やっぱ一時情報を当たるべきだな。。英語だけど下手な日本語解説より分かりやすかったりする。
matplotlib.org

figに入ってしまえばあとはtkに乗せるだけ。これは体重グラフで実績があるので楽勝だった。

開発からデプロイまでのフロー

以前はすべてのファイルをWinSCPで本番機に連携していたけど、管理にGitHubを使い始めたので少しマシになった。
f:id:t-hom:20210228135500p:plain

本当はClientで開発してDevにPushした後にDevでPullしてテストって方向で考えてたんだけど、実機でしかうまく動かない部分も出てくるし折角Vim使えるのにわざわざローカルで開発するのも面倒くさくて、結局開発は従来通り実機でやってしまった。そういう意味でラズパイ開発機は、いわゆるTest環境じゃなくて本当の意味でDev環境として使っている。

本番機の変更管理

デプロイの際の本番変更もしっかり管理。
f:id:t-hom:20210228125744p:plain

以上

M5 Stackのバッテリー容量と電源の切り方

M5 Stackに標準で付いてくるバッテリーは110mAhである。
これはつまり110ミリアンペアの電流を1時間流せるという意味だ。

ちなみにスマホのバッテリーは2500mAh~3000mAhくらいあるので、110mAhというのがいかに少ないかが分かっていただけるかと思う。
もちろん消費電力が全然違うのでスマホと比較するようなものではないが。


それでも以下のサイトによるとWiFiに繋いでると90~200mA程度消費するようだ。
ambidata.io

つまりWiFiにつなぎっぱなしにするような使い方では30分~1時間ほどでバッテリーを使いつくすと思われる。

そして消費電力を抑えるDeepsleepというモードでも10mA。
ということは、Deepsleepでも110mA/10mAで11時間しか持たないことになる。


そしてM5スタックを電源OFFする方法を調べていたところ、電源ボタンを素早く2回クリックすれば良いことが分かった。
ただし以下のサイトによると電源ボタンを素早く2回クリックしてもDeepsleepに移行するだけとのこと。
zenn.dev


これじゃ毎回バッテリーボトムを取り外さないと、11時間sleepさせておくだけでバッテリーを使い切ってしまうではないか。
(後に勘違いであることが分かる)

それで物理スイッチモジュールを発注してみたんだが、そういえば最後にチャージしてから4~5日経過してるけど普通に起動してくることに気づいた。
あれ、、、計算と違う。

ちゃんと公式のドキュメント読んでみたところ、「Power off: Quickly double-click the red power button on the left」とのこと。
docs.m5stack.com

DeepspleepじゃなくてちゃんとPower offって書いてある。。
公式がPower offって言ってるんだから前述の記事の方が間違ってるのでは。

更にGitHubで見つけた次のDiscussionによると、「This is a hardware problem of the IP5306 5V buck converter going into standby after 32 seconds when the load current remains below 45mA.」とのこと。
github.com

つまり、M5 Stackはバッテリー駆動時に消費電力が45mAを32秒以上下回った場合はスタンバイに移行するとのこと。

仮に電源ボタンを素早く2回クリックする操作がDeepsleepであるという記事が正しかったとしても、Deepsleepの消費電力は10mA程度なので32秒でスタンバイに移行するということである。

物理スイッチを使用すればStandbyよりも更に消費電力が低いものと思われるが、ちょっとこのスイッチケースも微妙なところがあり。。
f:id:t-hom:20210227182346p:plain

公式の謳う電OFFの方法で電源を切っておいた場合、4~5日後も普通に使用できているので、私の運用方法では別にわざわざ物理スイッチ付けなくても良いかなと思った。

GitHubに誤って自宅WiFiパスワードをPushしてしまったので対処した件

GitHubを使い始めた背景

最近、Raspberry PiやM5 Stackでの開発が本格化してきたため、レガシーなファイル名によるバージョン管理では限界が来ている。
ラズパイの本番機は先日綺麗にしたのだが、開発機はこのありさま。。
f:id:t-hom:20210227132729p:plain

ステージング用に準備したフォルダの筈が、面倒くさくなってVNC接続してそのままVimで色々と作り始めた次第。
完全にworkフォルダの二の舞に。。

ちなみにworkフォルダの様子。。もう何のプロジェクト用だったか分からないファイルが沢山。
f:id:t-hom:20210227132917p:plain

いい加減Gitで管理しようということで、再入門してみた。
※個人のVBAプロジェクトで見よう見まねで使ったことはあるが、書籍を通しで読んで本格的にやるのは初めて。

購入したのはこちらの書籍。

わかばちゃんと学ぶ Git使い方入門

わかばちゃんと学ぶ Git使い方入門

一切コマンドが登場せず、SourceTreeというGuiツールのみで解説されているので、操作の種類や概念を学ぶのにとても良い。
ただし、記載内容はやや古く、SourceTreeの導入部分と、GitHubのプライベートリポジトリが有料という記載は現在は当てはまらない。

トラブル発生

さくっと読了して早速Raspberry PiとM5 StackのプロジェクトをGitHubにPushしたのだが。。。
そこで凡ミス発生。

M5 Stack用のソースコードにssidとpasswordが記載された状態でそのままアップロードしてしまったのだ。

Gitはバージョン管理システムなので、普通に消しても履歴上は残ってしまう。つまり過去履歴をたどるとssidとpasswordが出てきてしまう。
今回はPrivateリポジトリにしていたので他人に見られることは無くてセーフなんだけど、Publicにしてたらと思うとゾッとするミスである。

履歴の削除対応

履歴の削除は次のように行うようだ。結局コマンドが必要になる。
docs.github.com

なお、記載されているBFG は今ひとつ使い方が分からなかったので、filter-branch を使用することにした。
書かれている手順でやったことは次のとおり。

1) 機密データを含むリポジトリのローカルコピーが履歴にまだない場合は、ローカルコンピュータにリポジトリのクローンを作成します。

ローカルで作業してるのでクローンする必要はないかと思ったけど、指示と違うことをやってしまうと怖いので、現在Source Treeで作業しているフォルダーとは違う場所にフォルダーを作って、GitHubからCloneした。

2) リポジトリのワーキングディレクトリに移動します。

そのとおり作業

3) 次のコマンドを実行します。機密データを含むファイルへのパスは、ファイル名だけではなく、削除するファイルへのパスで置き換えます。

コマンドが示されているが、Unix/Linuxに慣れてないと意味が分からんと思う。GitHub使う時点で慣れてる前提かな。
とりあえず補足しておいた。
f:id:t-hom:20210227135815p:plain

それから、PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATAは削除したいファイルのパスに置き換えるらしいんだけど、「パス」って何のパスなのかも初心者には辛いところ。

GitHub上にパスがあればコピーしてくれば良いんだけど、ここでは消したいファイルを削除してコミットした前提でたどり方を説明する。なお、私の環境では本当に消したいファイルは既に消したあとなので、このあとの説明は適当なファイルを例に挙げている。

過去に消したファイルには、コミット履歴から辿れるので、まずはコミットログを開く。
f:id:t-hom:20210227140545p:plain

ファイルを削除する前のコミット履歴を開く
f:id:t-hom:20210227140833p:plain

Browse Fileをクリック
f:id:t-hom:20210227141016p:plain

特定の履歴に飛ぶのでフォルダーから消したいファイルまでたどる。
f:id:t-hom:20210227141232p:plain

ファイルを開いたらメニューからCopy Pathでクリップボードに値がコピーされる。
f:id:t-hom:20210227141604p:plain

あとはコマンドのPATH-TO-YOUR-FILE-WITH-SENSITIVE-DATAの部分をコピーしたパスに置き換える。
例えばcalorie_graph.pyを履歴から削除したい場合は次のコマンドになる。改行せずに1行で入力しきったのでバックスラッシュはない。

git filter-branch --force --index-filter "git rm --cached --ignore-unmatch pi/m5_visualizer/calorie_graph.py" --prune-empty --tag-name-filter cat -- --all

4) 機密データを含むファイルを、誤って再度コミットしないようにするため、.gitignore に追加します。

その通り対応。
YOUR-FILE-WITH-SENSITIVE-DATAをファイル名に置き換える。これはパスじゃなくて単にファイル名でOK。

5) リポジトリの履歴から削除対象をすべて削除したこと、すべてのブランチがチェックアウトされたことをダブルチェックします。

具体的にどうするのか分からなかったのでやってない。要は確認なので、所詮情報の消し漏れで困るのは私ひとりの為、この項目はスキップした。業務で対応してる方はちゃんと調べるなり、サポートに問い合わせるなりしないとまずいと思う。

6) リポジトリの状態が整ったら、ローカルでの変更をフォースプッシュして、GitHub リポジトリと、プッシュしたすべてのブランチに上書きします。

その通り対応。

7) 機密データをタグ付きリリースから削除するため、Git タグに対しても次のようにフォースプッシュする必要があります。

その通り対応したが、結果は「すでに対応済」のようなメッセージが出て特に何も起こらなかった。

8) GitHub Support or GitHub Premium Support に連絡し、GitHub 上で、キャッシュされているビューと、プルリクエストでの機密データへの参照を削除するよう依頼します。

その通り対応。ここまでの操作ログを張り付けて、前述のページに従って作業したのでキャッシュ削除して欲しい旨を英語で連絡した。返事待ち。

9) コラボレータには、 作成したブランチを古い (汚染された) リポジトリ履歴からリベースする (マージしない) よう伝えます。 マージコミットを 1 回でも行うと、パージで問題が発生したばかりの汚染された履歴の一部または全部が再導入されてしまいます。

具体的にどうすれば良いのか不明で私ひとりの開発なので、この作業の代わりにSourceTreeで使っていたローカルのファイルをすべて削除したうえ、GitHubから新たにプロジェクトをクローンしてきてそちらを正とした。

10) 一定の時間が経過し、git filter-branch に意図しない副作用がないことが確信できるようになったら、次のコマンドによって、ローカルリポジトリのすべてのオブジェクトが強制的に参照から外されガベージコレクトされるようにします (Git 1.8.5 以降を使用)。

これもよく分からないので特に何もしてない。
恐らくGitHubからクローンしなおしてるので問題ないと思う。

以上で履歴の削除対応は完了とした。
繰り返しになるが、仕事に使ってる方や完全削除しないと他人に迷惑のかかるデータを消す場合は、サポートに問い合わせをしてでも作業を完遂したことを確認すべきだと思う。

私の場合は最悪自分しか困らないシチュエーションだったのである程度緩い対応で済ませたが、真似して痛い目を見ても責任は負いかねるのであしからず。

再発防止

Wi-Fi接続用のSSIDとパスワード情報を他のファイルに分離することにした。
ありがたいことに以下のサイトで実例を見つけたので真似してやってみた。
scrapbox.io

ライブラリフォルダってどこなのか一瞬分からなかったので備忘録としてスクショ張っておく。
f:id:t-hom:20210227143535p:plain

このヘッダーのコードは参照先サイトと同じく、そのまま。
f:id:t-hom:20210227143757p:plain

前述の場所に置いておくとスケッチメニューからライブラリのインクルードでssid_defineが見つかるので、メインコードを開いた状態でそれをクリックすると、include文がコードの先頭に追記される。
参照先サイトではdefine文でssid_defineを取り込むような表記になってたけど、エラーになったので多分記載ミスだと思う。

あとはこんな感じでメインコードからMY_SSIDとMY_SSID_PASSを参照できるようになる。

#include <ssid_define.h>
#include <M5Stack.h>
#include <WiFi.h>

int p;
int digit[4];

const char* ssid     = MY_SSID;
const char* password = MY_SSID_PASS;

//以下略

これでこのコードをGitHubにアップロードしてもパスワード流出の危険はなくなった。

終わりに

今回はPrivateリポジトリで発生したミスなので流出の危険性は低く、しかも自分の自宅Wifiパスワードなので万が一流出しても困るのは私だけ、また所詮電波の届く範囲からしか接続されることはないのでリスクはほとんど無いようなものである。

だからじっくり対応できたしこの程度の対応で済ませたんだけど、もしこれが公開リポジトリで、他人の個人情報や公開サービスのパスワードなんてことになったら目も当てられない。

なので、GitHub使い始めたばかりの方はこういう凡ミスを起こしたときに焦らず確実に対処できるように、問題のないファイルを使って履歴削除の練習をしておくと良いと思う。
※サポートへのキャッシュ削除依頼は、本当に削除が必要な場合以外はしないでください。

以上。ああ、疲れた。

2/28 追記

GitHub Supportから返信が来てサーバー側のキャッシュをクリアしてくれたので追記。

私がサポートに送った内容全文

メールではなくてWebからの問い合わせとなる。markdown記法が使えるので#が付いてるのは見出し。
依頼事項と対照リポジトリ/ファイル/操作内容をコマンドプロンプトから貼り付けておいた。
コマンドと結果が分かりづらいのでコマンドだけ赤色にしておいた。

このときはmainブランチしかなかったので特にブランチ指定はしてないけど、複数ブランチに消したいファイルがあれば伝えた方が良いのかもしれない。
ただ後述する返信を見るとリポジトリに対してガベージコレクションをかけただけっぽいのでそんなに細かい内容を送らなくても良かったのかも。
まぁ送って間違いはないと思う。何か間違ってることがあればついでに指摘してくれるだろうし。

Dear Person in Charge,


Sorry to bother you. I wrongly pushed a file which include Wi-Fi password to my repository.
I've finished step 7 of following instruction.
https://docs.github.com/en/github/authenticating-to-github/removing-sensitive-data-from-a-repository#purging-a-file-from-your-repositorys-history


Could you please remove cached view?


Note: please tell me if I'm asking something wrong. I'm almost newbie for Git.

# Repository(Private)

https://github.com/thom-jp/health-tracking-pi

# Removal Target

m5/client/client.ino

# Removal Operation Log

Microsoft Windows [Version 10.0.19041.804]
(c) 2020 Microsoft Corporation. All rights reserved.

C:\Users\ho_>cd OneDrive\dev\htp

C:\Users\ho_\OneDrive\dev\htp>git clone https://github.com/thom-jp/health-tracking-pi
Cloning into 'health-tracking-pi'...
remote: Enumerating objects: 71, done.
remote: Counting objects: 100% (71/71), done.
remote: Compressing objects: 100% (67/67), done.
remote: Total 71 (delta 5), reused 63 (delta 1), pack-reused 0
Receiving objects: 100% (71/71), 705.90 KiB | 966.00 KiB/s, done.
Resolving deltas: 100% (5/5), done.

C:\Users\ho_\OneDrive\dev\htp>cd health-tracking-pi

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git filter-branch --force --index-filter "git rm --cached --ignore-unmatch m5/client/client.ino" --prune-empty --tag-name-filter cat -- --all
WARNING: git-filter-branch has a glut of gotchas generating mangled history
rewrites. Hit Ctrl-C before proceeding to abort, then use an
alternative filtering tool such as 'git filter-repo'
(https://github.com/newren/git-filter-repo/) instead. See the
filter-branch manual page for more details; to squelch this warning,
set FILTER_BRANCH_SQUELCH_WARNING=1.
Proceeding with filter-branch...

Rewrite 31f5c3bed9103b44a88e1111f3d63b26d6279447 (5/7) (3 seconds passed, remaining 1 predicted) rm 'm5/client/client.ino'
Rewrite 4c74c75abb9cea3100de7586e183411f9ae0eebd (7/7) (4 seconds passed, remaining 0 predicted)
Ref 'refs/heads/main' was rewritten
Ref 'refs/remotes/origin/main' was rewritten
WARNING: Ref 'refs/remotes/origin/main' is unchanged

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>echo "m5/client/client.ino" >> .gitignore

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git add .gitignore

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git commit -m "Add client.ino to .gitignore"
[main c945e1d] Add client.ino to .gitignore
1 file changed, 1 insertion(+)
create mode 100644 .gitignore

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git push origin --force --all
Enumerating objects: 69, done.
Counting objects: 100% (69/69), done.
Delta compression using up to 6 threads
Compressing objects: 100% (64/64), done.
Writing objects: 100% (69/69), 704.03 KiB | 140.81 MiB/s, done.
Total 69 (delta 5), reused 60 (delta 3), pack-reused 0
remote: Resolving deltas: 100% (5/5), done.
To https://github.com/thom-jp/health-tracking-pi

- 4c74c75...c945e1d main -> main (forced update)

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git push origin --force --tags
Everything up-to-date

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>

GitHubサポートからの返信メール

Thanks for reaching out! I've run a garbage collection and cleared our cached views for `thom-jp/health-tracking-pi`, so any sensitive data removed should no longer be accessible on GitHub.
If there are any remaining commits with sensitive data in them, you can send us the SHAs and we can check where else those commits might still be referenced. Otherwise, feel free to let us know if there's anything else we can assist you with!

お礼の返信もしておいた

I really appreciate for your prompt action.
Wishing you all the success in your future endeavors.

Sincerely Yours,
Thom

私もヘルプデスクで働いた経験があるが、丁寧にお礼を言ってくれるユーザーは意外と覚えているもので、次回の対応は率先して行うようになる。チームで対応してるのでSLAのファーストレスポンスギリギリまで取らない案件と、率先してピックアップされる案件がある。だって丁寧なユーザーでさっさと自分の手を埋めてしまったほうが横柄なユーザーが他のスタッフに回るし。Hahaha.

Python Pandasで週ごとの集計~週間運動時間グラフ表示

前回は摂取カロリー記録に使用しているM5 Stackに運動時間を入力する仕組みを追加した。
thom.hateblo.jp

入力されたデータをWifiでラズパイに飛ばして記録するところまでは完成している。

今回は記録した運動時間をmatplotlibでグラフ化して表示させる部分を作成した。

動作イメージ

週単位で運動時間を集計し、棒グラフがピンクの斜線のエリアに到達すればその週のノルマは達成したという意味になる。
f:id:t-hom:20210225003552p:plain

もともと紙で記録していた運動時間をシステム化したものであるが、実際の運動量を入力したところ2週間前の運動時間が150分に満たないことが判明。
紙の運用で△を全部潰したので達成!って思ってたんだけど、そういえば最低1つ〇を消し込んで達成というルールで設計したのを忘れていた。
f:id:t-hom:20210225003750p:plain

電子記録でグラフ化すればこんな間違いも防げる。

コード

カロリー記録よりもシンプルに見えて、実際には今回の方が難しかった。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, date, timedelta
df = pd.read_csv("/home/pi/test_exercise.csv", header=None, names=["timestamp", "exercise_time"], parse_dates=["timestamp"])

print('\n--print dataframe which generated from csv--')
print(df)


base_day = datetime.now()
base_day = base_day.replace(hour=0, minute=0, second=0, microsecond=0)
print(base_day)
while base_day.weekday() != 5:
    base_day = base_day+timedelta(days=1)

dummy_data = []
for i in range(0, 7*7)[::-1]:
    sublist = []
    sublist.append((base_day -timedelta(days=i)))
    sublist.append(0)
    dummy_data.append(sublist)

dummy_columns = ["timestamp","exercise_time"]
dummy_df = pd.DataFrame(data=dummy_data,columns=dummy_columns)

print('\n--print dummy dataframe for data completeness--')
print(dummy_df)

df = df.append(dummy_df,ignore_index=True)

df["date"] = df["timestamp"].dt.strftime("%Y-%m-%d")
df = df.groupby(["date"]).agg({"exercise_time":"sum"}).reset_index()

print('\n--print completed data--')
print(df)

df["week"] = df['date'].apply(lambda x: -1 * ((base_day - datetime.strptime(x, '%Y-%m-%d')).days // 7))
df = df.groupby(['week']).agg({"exercise_time":"sum"}).reset_index()
print(df)


print('\n--print tailed data--')
df = df.tail(7)
print(df)


week = df["week"].values
exercise_time = df["exercise_time"].values
minimum = np.array([150 for i in df.index])
maximum = np.array([300 for i in df.index])
 
plt.title("Exercise Record", fontsize = 22)
plt.xlabel("Week", fontsize = 22)
plt.ylabel("Time", fontsize = 22)
plt.grid(True)
 

plt.bar(week, maximum-minimum, width=0.25, bottom = minimum, tick_label = week, align="center", label="Guideline", color = "none", edgecolor="#ff1493", lw=0, hatch="/////")

plt.bar(week, exercise_time, width=0.2, tick_label = week, align="center", label="Breakfast", color = "#dc143c")

plt.show()


悩んだポイントは「どうやって週単位のデータに変換するか」である。

1週間の定義を日曜~土曜としたいのだが、集計日を基準にしてしまうと1週間の範囲が日々変動することになる。

そこで、基準日(base_day)に一旦Nowを代入した後、その曜日(Weekday)が土曜になるまでbase_dayを進める。
こうしていつ集計しても当週の土曜が最終データになるようにダミーデータが作られ、基準日からの日数を7で割った整数に-1を掛けることで「何週前」を表現することにした。

今回新たに覚えた技は、データフレームのシリーズに対する加工である。
シリーズというのはデータ列のことで、例えば日付もシリーズとして格納されている。

シリーズ同士の計算はpythonが勝手にやってくれるんだけど、シリーズと単一データの計算は普通にやってもうまくいかない。
例えば次のような計算は失敗する。

df['week']  = base_day - df['date']

正しく計算するには、計算をラムダ関数にしてシリーズに適用させれば良いことが分かった。

df["week"] = df['date'].apply(lambda x: -1 * ((base_day - datetime.strptime(x, '%Y-%m-%d')).days // 7))

ああなるほど、関数型言語だなぁと思った。
また一つ、pandasの扱いが上達した気がして満足である。

おわりに

これで体重記録・摂取カロリー記録・運動量記録が揃った。
あとダイエット関連で足りないのは筋トレか。これは種目ごとの積み上げグラフとかにしたいなぁと思うけど、そもそも筋トレは始めてすらいないので紙での記録開始がまず先決かな。

開発するなら、ついでにメトロノームも実装してしまおうかと思う。

M5 Stack Basic ボタン長押しで動作モード変更するプログラム

先日実装した摂取カロリー記録システムだが、既に紙での記録より便利なので2末を待たずに電子記録に完全移行した。
実はカロリー以外にも一週間の運動時間を紙に記録していたのだが、カロリー記録で味をしめたのでこちらも電子化したい。

単純にもう一台M5 Stackを買ってクライアントごと分けるということを思いついたけど、そんなことをせずともボタンの長押しを活用すれば1台でカバーできることに気づいた。

つまりカロリー記録モードと運動時間記録モードを長押しで切り替えるのである。
図示するとこんな感じ。
f:id:t-hom:20210223210248p:plain

それで、開発を始めることにしたのだが、既存のM5 Stackはカロリー記録で本番稼働中なので一時的とはいえ開発中のプログラムを書き込んだり本番プログラムにロールバックしたりというのは面倒くさい。
本番稼働に影響を与えない形で開発したい。

というわけで、結局2代目ゲット。
f:id:t-hom:20210223211546p:plain

外観が全く同じなので、テプラでPRD・DEVシールを張って見分けるようにした。

M5 Stackのコード

力業で機能拡張したので褒められた内容ではないけど、とりあえず掲載。

//Under Development
#include <M5Stack.h>
#include <WiFi.h>

int p;
int digit[4];
char modeChr;

const char* ssid     = "xx999x-999x99-9";
const char* password = "99x999xx9999x";

const int port = 49153;
const IPAddress server_ip(192, 168, 1, 101);
WiFiClient client;

void setup() {
  // init lcd, serial, but don't init sd card
  M5.begin(true, false, true);
  M5.Power.begin();

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
      delay(500);
  }
  modeChr = 'C';
  resetNumber();
}

// Add the main program code into the continuous loop() function
void loop() {
  // update button state
  M5.update();
 
  // if you want to use Releasefor("was released for"), use .wasReleasefor(int time) below
  if (M5.BtnA.wasReleased() || M5.BtnA.pressedFor(1000, 200)) {
    if(digit[p]++ >= 9){digit[p]=0;}
    displayNumber();
  } else if (M5.BtnB.wasReleased() || M5.BtnB.pressedFor(1000, 200)) {
    if(++p >= 4){p=0;}
    displayNumber();
  } else if (M5.BtnC.wasReleased() || M5.BtnC.pressedFor(1000, 200)) {
    int x = 0;
    x = digit[0]*1000 + digit[1]*100 + digit[2]*10 + digit[3];
    
    bool result = sendData(x);
    if (result){
      M5.Lcd.println("Done!");
      modeChr = 'C';
    }
    else {
      M5.Lcd.println("Failed!");
    }
    soundFeedback(result);
    
    delay(1000);
    resetNumber();
  } else if (M5.BtnB.wasReleasefor(700)) {
    resetNumber();
  } else if (M5.BtnA.wasReleasefor(700)) {
    if (modeChr == 'C') {
      modeChr = 'E';
    } else {
      modeChr = 'C';
    }
    resetNumber();
  }
}

void soundFeedback(bool result){
    if (result){
      M5.Speaker.tone(659, 200);
      delay(200);
    }
    else {
      M5.Speaker.tone(440, 100);
      delay(100);
      M5.Speaker.mute();
      delay(100);
      M5.Speaker.tone(440, 100);
      delay(100);
      M5.Speaker.mute();
      delay(100);
      M5.Speaker.tone(440, 100);
      delay(100);
    }
    M5.Speaker.mute();
}

bool sendData(int n) {
  M5.Lcd.setTextSize(2);
  M5.Lcd.println("Connecting...");

  if (modeChr == 'C') {
    M5.Lcd.println("Sending " + String(n) + " kcal.");  
  } else {
    M5.Lcd.println("Sending " + String(n) + " mins.");  
  }
  
  if (!client.connect(server_ip, port)) {
      return false;
  }
  if (modeChr == 'C') {
    client.print("C " + String(n));
  } else {
    client.print("E " + String(n));
  }
  
  return true;
}

void resetNumber() {
  if(modeChr =='C') {
    p = 1;
  } else {
    p = 2;
  }
  for (int i=0; i<4; i++){
    digit[i] = {0};
  }
  displayNumber();
}

void displayNumber() {
  M5.Lcd.clear(BLACK);
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.setTextSize(10);
  if (modeChr == 'C') {
    for(int i=0; i<4; i++) {
      if(i==p){M5.Lcd.setTextColor(YELLOW);}
      else {M5.Lcd.setTextColor(WHITE);}
      M5.Lcd.print(digit[i]);
    }
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(5);
    M5.Lcd.setCursor(180, 18);
    M5.Lcd.println("kcal");
  } else {
    for(int i=0; i<4; i++) {
      if(i==p){M5.Lcd.setTextColor(RED);}
      else {M5.Lcd.setTextColor(WHITE);}
      M5.Lcd.print(digit[i]);
    }
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(5);
    M5.Lcd.setCursor(180, 18);
    M5.Lcd.println("mins");
  }
}

サーバープログラムも1つで、送信する数値に1文字のモードコード(C=Calorie, E=Exercise)を付加することで書き込み先のファイルを判定させるようにした。
PythonでSplitしやすいようにコードと値の間はスペース区切りとしている。

開発中プログラムでは接続先ポートも変えて、万が一本番側のサーバーが稼働していてもつながらないようにした。

ラズパイ側のコード

こっちもまあ褒められたものではない。

import socket
import time
import csv
import os
from datetime import datetime

def meal_type(timestamp):
    if 0 <= timestamp.hour <= 10:
        return "Breakfast"
    elif 11 <= timestamp.hour <= 16:
        return "Lunch"
    else:
        return "Dinner"

def record_calorie(cal):
    file_path = '/home/pi/test_calorie.csv'
    with open(file_path,'a',newline='') as f:
        w = csv.writer(f)
        timestamp =  datetime.now()
        w.writerow([timestamp.strftime('%Y-%m-%d %H:%M:%S'), meal_type(timestamp), cal])
        print([timestamp.strftime('%Y-%m-%d %H:%M:%S'), meal_type(timestamp) ,cal])

def record_exercise(minutes):
    file_path = '/home/pi/test_exercise.csv'
    with open(file_path,'a',newline='') as f:
        w = csv.writer(f)
        timestamp =  datetime.now()
        w.writerow([timestamp.strftime('%Y-%m-%d %H:%M:%S'), minutes])
        print([timestamp.strftime('%Y-%m-%d %H:%M:%S'), minutes])

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind(("", 49153))
    while True:
        s.listen(1)
        conn, addr = s.accept()
        try:
            data = conn.recv(16).decode('utf8').split()
            print(data)
            if data[0] == "C":
                record_calorie(int(data[1]))
            elif data[0] == "E":
                record_exercise(int(data[1]))
            time.sleep(1)
        except socket.error:
            pass
        except KeyboardInterrupt:
            conn.close()
            s.close()
        conn.close()

受信したデータをカンマで区切って、最初のデータがCかEかで書き込み先のファイルや書き込み内容を振り分けている。
とりいそぎ、うまくいったようなので集計してグラフ表示させるプログラムができたら本番運用に乗せられる。

最後今回の開発がPRDサーバーとPRDクライアントに影響を与えていないことを確認するためにテスト書き込みを行って、テストデータを消しておしまい。

以上

新しくWebカメラを買う前に、部屋が映る範囲をFusion360で検証してみた。

最近オンライン英会話を始めたのでWebカメラを使うんだけど、画角が広すぎて困っている。
部屋がかなり広く映り込むので、片付けをサボると英会話に参加するハードルが上がるのだ。
グリーンバックを買えば良いのかもしれないが、そもそもグリーンバックでカバーできるのかもよく分かってない。

もう少し狭角のカメラが欲しいんだけど、そもそも今のカメラでどこまで映ってるのかを製図ソフトFusion 360でシミュレートしてみた。

今使っているのはこちら。広角120度とある。

部屋のサイズを測って、カメラの位置や理論上映り込むであろ120度のラインをFusion360で書いてみた。
f:id:t-hom:20210221134850p:plain
※建築用CADってわけでもないのでメートルはセンチ→ミリに縮尺変換している。

赤丸の位置がカメラである。Bandicamという録画ツールで実際にカメラ映像を見てみると、概ね理論どおりの範囲が映り込んでいることが分かった。

画角60度の製品を見つけたのでこちらもシミュレートしてみる。
f:id:t-hom:20210221140224p:plain

そうすると、かなり安全地帯(映り込まない場所)が増えることが分かった。
またグリーンバックの設置位置を検討したところ、最低幅160cm程度は必要であることが分かった。
(追記:と思ったけど人物と背景の境界認識できれば良いのでそんなデカいのは要らないのかも。市販品にそんな横幅が広いものはあまりなかった。)

ということで、まずはカメラの調達に出かけようと思う。

以上

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。