コーヒースケールって何だろう
最近、コーヒースケール(ドリップスケール)というものを知りました。ストップウォッチが付いた「はかり」みたいなものです。
コーヒーをドリップしながら、注いだお湯の量と経過時間を表示していきます。
お湯の注ぎ方にも「レシピ」があるようです。45秒間隔でまずxx%、次にxx%・・・と5回に分けて注ぐ、みたいな。
これって、時計付きのはかりだから、前に作った「切れ味測定器」と同じ?
市販品を見てみると、トータルの重さしか測定していません。ドリッパーとカップの両方で測定すれば、注いだお湯の量とできたコーヒーの量、そしてドリッパーを通過する速度がわかるのに。また、データを保存できるものもありますが、高価です。
家にあったパーツで作ってみた
3Dプリンタを作ったときのアルミフレームと、切れ味測定器を作ったときに購入したロードセル、あとはM5StackとHX711のモジュール2枚とロードセルを組み合わせて、プロトタイプを作ってみました。アクリルの部材は切れ味測定器用にカットしたものを流用。
材料
- アルミフレーム
- HX711モジュール (x2)
- ロードセル (x2)(5kg用)
- M5Stack
- プロトタイピング基板
- その他、ビスやアクリル版など
接続方法
2枚のHX711モジュールにロードセルを接続し、モジュールをM5Stackに接続します。モジュールの裏面のパターンをカットして、80サンプル/秒で測定しています。
- HX711モジュール(ドリッパー用)とM5Stackの接続: DAT-22 CLK-21
- HX711モジュール(カップ用)とM5Stackの接続:DAT-36 CLK-26
両方のモジュールのVCCとGNDも、それぞれM5Stackの3.3Vか5V、GNDに接続します。
モジュールとロードセルは、赤、黒、白、緑の4線を指定された場所に接続します。
プログラム
以下、ちょっと長いですがプログラムです。HX711のライブラリはいろいろありますが、コメントに記載したものを使用しました。WiFiに接続するためのSSIDとパスワードは各自設定してください。
コーヒースケール(プロトタイプ)
#include <Arduino.h>
#include <HX711.h> //https://github.com/bogde
#include <M5Stack.h>
#include <WiFiClientSecure.h>
#include "time.h"
//ネットワーク、Google Spreadsheet関連
const char* M5ID = "CoffeeScale_"; //M5識別用ID、ファイル名にいれる
const char* ssid = "xxxxxxxxxx"; // your network SSID (name of wifi network)
const char* password = "xxxxxxxxxx"; // your network password
const char* host = "script.google.com";
String exec_url = "https://script.google.com/macros/s/xxxxxxxxxx/exec";//postするためのurl
WiFiClientSecure client;
boolean WiFiOn; // WiFi接続したらtrue
String filename, sheetname;// ファイル名、シート名
//****** 時計用 ******
const char* ntpServer = "ntp.jst.mfeed.ad.jp";
const long gmtOffset_sec = 9 * 3600; // 時刻取得用、
const int daylightOffset_sec = 0;
struct tm timeinfo;
//サウンド関連
#define DAC_PIN 25
uint32_t toneEndMillis;//なっている音を止めるmillis
uint8_t tone_volume = 10; //min 1, max 255
uint8_t toneNum, toneStep;//曲番号、音符番号
boolean toneOff = true;//音が出ているとfalse
//音名と1/2波長(マイクロ秒)
const int16_t C_1 =1902, Cis_1 =1796, D_1 =1695, Dis_1 =1600, E_1 =1510, F_1 =1425,
Fis_1 =1345, G_1 =1270, Gis_1 =1198, A_1 =1131, Ais_1 =1068, H_1 =1008,
C_2 =951;
typedef struct {
uint16_t tone;
uint16_t duration;
} toneData;
const toneData myTone[4][2] = {
{{C_1, 50},{G_1, 100}}, //計測開始
{{E_1, 50},{A_1,70}}, //レシピ次ステップへ
{{G_1, 100},{C_2,200}}, //注湯終了
{{G_1, 150},{C_1, 150}} //測定終了
};
//タイマー関連
#define TIMER0 0
hw_timer_t * samplingTimer = NULL;
volatile int t0flag;
//****** HX711 pins etc ******
//Dripper
const int DATDrp = 22;
const int CLKDrp = 21;
//Cup
const int DATCup = 36;
const int CLKCup = 26;
HX711 scaleDrp, scaleCup;//HX711 for dripper and cup
double weightDrp, weightCup;//計測したドリッパーとカップの重量
double weightDrpAvg = 0, weightCupAvg = 0;//表示するドリッパーとカップの重量
double weightDrpPrev=0, weightCupPrev=0;//前回のドリッパーとカップの重量
double weightDrpDisp=0, weightCupDisp=0;//表示用のドリッパーとカップの重量
double weightDrpMax=0, weightCupMax=0;//ドリッパーとカップの最大値
double weightPrev = 0; //抽出速度計算用
double dripVelocity; //抽出速度
double weightDrpData[300], weightCupData[300];//データ保存用配列
int targetData[300];//表示されていたターゲット重量保存用配列
long dataSavedMillis = 0;//前回保存の時刻
int16_t secCounter = 0;//データ記録のインデックス 経過秒数をカウント
int target = 100; //抽出するコーヒーの量の初期値
int recipe[] = {18, 40, 60, 80, 100}; //抽出のレシピ 100分率積算で指定 現状固定
int recipeSteps = sizeof(recipe)/sizeof(int);//レシピの総ステップ数
int recipeCount = 0; //レシピの何ステップ目にいるか
int minute = 0, sec = 0; //経過時間表示用の分と秒
long startMillis = 0; //計測スタート時点のミリ秒
long soundMillis; //音を出し始めたミリ秒
bool measuring = false; //計測中かどうか
bool targetReached = false; //注湯量がターゲット到達
bool measureEnded = false; //ドリッパー外して計測終了
bool targetSet = false; //目標重量設定済かどうか
int gap;//レシピの重量と現在の重量の差
void LcdInit(){
M5.Lcd.clear(BLACK);
M5.Lcd.setTextSize(2);
M5.Lcd.setTextColor(WHITE, BLACK);
M5.Lcd.setCursor(0, 0);
}
void connectingWiFi(){
int n_trial, max_trial = 10; //WiFi接続試行回数とその上限
WiFi.mode(WIFI_STA);
WiFi.disconnect();
WiFi.begin(ssid, password);
LcdInit();
M5.Lcd.print("Connecting to WiFi");
// attempt to connect to Wifi network:
n_trial = 0;
while (WiFi.status() != WL_CONNECTED && n_trial < max_trial) {
M5.Lcd.print(".");
// wait 1 second for re-trying
n_trial++;
delay(1000);
}
LcdInit();
if (WiFi.status() == WL_CONNECTED)
{WiFiOn = true;
M5.Lcd.print("Connected to ");
M5.Lcd.println(ssid);
M5.Lcd.print("IP: ");
M5.Lcd.println(WiFi.localIP()); }
else
{WiFiOn = false;
M5.Lcd.print("WiFi connection failed"); }
delay(500);
}
void IRAM_ATTR onTimer0() { // タイマ割込関数
if(t0flag == 1){
dacWrite(DAC_PIN, tone_volume);
t0flag = 0;}
else{
dacWrite(DAC_PIN, 0);
t0flag = 1;
}
}
//音を出す
void toneStart(int16_t t_length, int16_t t_duration){
timerAlarmWrite(samplingTimer, t_length, true); // 周期 マイクロ秒単位
timerAlarmEnable(samplingTimer); // タイマを動かして音を出す
toneEndMillis = millis()+t_duration;//グローバル変数に音を止めるmillisをセット
toneOff = false;//音がなっている
}
//ドリッパーとカップをnumMeasure回計測して平均する
void measureWeight(int numMeasure){
weightDrp = 0;
weightCup = 0;
for (int i=0; i<numMeasure; i++){
weightDrp += scaleDrp.get_units();
weightCup += scaleCup.get_units();
}
weightDrp /= numMeasure;
weightCup /= numMeasure;
}
//データをpostする
String postValues(String values_to_post) {
M5.Lcd.println("postValues");delay(1000);
if (client.connect(host, 443)){
M5.Lcd.println("Posting data...");
client.println("POST " + exec_url + " HTTP/1.1");
client.println("HOST: " + (String)host);
client.println("Connection: close");
client.println("Content-Type: text/plain");
client.print("Content-Length: ");
client.println(values_to_post.length());
client.println();
client.println(values_to_post);
delay(100);
while (client.available()) {
char c = client.read();
M5.Lcd.print(c);
}
client.stop();
return "post end";
}
else {
return "ERROR";
}
}
//グラフ描画
void drawGraph(){
int i;//ループ用
int XindexStep, YindexStep;//X軸、Y軸の数字の見出し間隔
double xStep, yStep, maxY=0;
LcdInit();
for (i=0;i<secCounter;i++){//secCounterは最終保存インデックス+1、すなわちデータの個数になっている
if (weightDrpData[i]+weightCupData[i] > maxY){maxY = weightDrpData[i] + weightCupData[i];}//重量データの最大値を求める
if (targetData[i] > maxY){maxY = targetData[i];}//目標データが最大値になる場合もある 途中でやめた場合など
}
//プロットの範囲は(10,3)から(316,210)とする。円全体がはみ出さないように。
xStep = 306/(double)secCounter;
yStep = 207/(double)maxY;
Serial.print("xStep=");Serial.println(xStep);
if (secCounter > 200){XindexStep = 100;}//X軸の見出しを100毎に
else if (secCounter > 50){XindexStep = 10;}//10毎に
else XindexStep = 5;//5毎に
if (maxY > 200){YindexStep = 100;}
else if (maxY > 50){YindexStep = 10;}
else YindexStep = 5;
//軸と見出し
M5.Lcd.drawLine(10,0,10,210,TFT_WHITE);//Y軸
M5.Lcd.drawLine(10,210,319,210,TFT_WHITE);//x軸
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(TFT_WHITE,TFT_BLACK);
M5.Lcd.setCursor(8,213);
M5.Lcd.print("0");
for (i=1;i<=20;i++){ //見出しの数は最大20個
if (i * XindexStep * xStep +8 < M5.Lcd.width()){
M5.Lcd.setCursor(i * XindexStep * xStep + 8 ,213);
M5.Lcd.print(i*XindexStep);
}
if (208 - i * YindexStep * yStep < M5.Lcd.height()){
M5.Lcd.setCursor(0, 208 - i * YindexStep * yStep);//文字は左上基準なので208とした
if (i*YindexStep < 100){M5.Lcd.print(" ");}//2桁の場合の位置合わせ
M5.Lcd.print(i*YindexStep);
}
}
//データプロット
for (i=0;i<secCounter;i++){
M5.Lcd.fillCircle(10 + i * xStep, 210 - weightCupData[i] * yStep, 2, TFT_WHITE);
M5.Lcd.fillCircle(10 + i * xStep, 210 - weightDrpData[i] * yStep, 2, TFT_GREEN);
M5.Lcd.fillCircle(10 + i * xStep, 210 - targetData[i] * yStep, 2, TFT_YELLOW);
M5.Lcd.fillCircle(10 + i * xStep, 210 - (weightDrpData[i] + weightCupData[i]) * yStep, 2, TFT_RED);
}
}
//データ保存か破棄か
void savingData(){
boolean saveOrDiscard = false;
M5.Lcd.setCursor(38,220);
M5.Lcd.setTextSize(2);
M5.Lcd.setTextColor(TFT_WHITE);
M5.Lcd.print("Save Reset ");
M5.Lcd.setCursor(30,100);
while(saveOrDiscard == false){
M5.update();
if (M5.BtnA.wasReleased()|| M5.BtnA.wasPressed()){
M5.Lcd.print("save");
saveOrDiscard = true;
}
if ((M5.BtnB.wasReleased()|| M5.BtnB.wasPressed()) && (saveOrDiscard == false)){
LcdInit();
M5.Lcd.print("Resetting");
delay(500);
M5.Power.reset();
}
}
LcdInit();
M5.Lcd.setTextSize(2);
M5.Lcd.println("saving data");
filename = M5ID; // ファイル名はM5IDで始める
filename += String(timeinfo.tm_year-100,DEC);//1900年から数えるので調整
if(timeinfo.tm_mon<9){filename += "0";}//0からはじまるので調整
filename += String(timeinfo.tm_mon+1,DEC);//0からはじまるので調整
if(timeinfo.tm_mday<10){filename += "0";}
filename += String(timeinfo.tm_mday,DEC);
filename += ".csv";
M5.Lcd.println("Filename =");M5.Lcd.println(filename);
sheetname =""; // シート名初期化 以下、時分秒からシート名作成
if(timeinfo.tm_hour<10){sheetname = "0";}
sheetname += String(timeinfo.tm_hour,DEC);
if(timeinfo.tm_min<10){sheetname += "0";}
sheetname += String(timeinfo.tm_min,DEC);
if(timeinfo.tm_sec<10){sheetname += "0";}
sheetname += String(timeinfo.tm_sec,DEC);
M5.Lcd.print("Sheetname =");M5.Lcd.println(sheetname);
String values = filename;
values += ",";
values += sheetname;
values += ",5,";//データは5列
values += "Second, Dripper, Cup, Target, Total";//1行目は見出し
for (int i=0;i<secCounter;i++){//以下、データを格納
values += ",";
values += i;
values += ",";
values += weightDrpData[i];
values += ",";
values += weightCupData[i];
values += ",";
values += targetData[i];
values += ",";
values += weightDrpData[i]+weightCupData[i];
}
String response = postValues(values);
M5.Lcd.print(response);
M5.Lcd.setCursor(30,210);//以下ボタンが押されるまで待ちリセット
M5.Lcd.setTextSize(3);
M5.Lcd.print(" Reset ");
M5.Lcd.setCursor(30,50);
M5.Lcd.setTextSize(4);
while(1){
M5.update();
if (M5.BtnB.wasReleased()|| M5.BtnB.wasPressed()){
LcdInit();
delay(100);
M5.Lcd.print("Resetting");
delay(500);
M5.Power.reset();
}
}
}
void setup() {
M5.begin();
Serial.begin(115200);
connectingWiFi();
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
M5.Lcd.println("configTime OK.");
getLocalTime(&timeinfo); //時刻取得
delay(1000);
M5.Lcd.clear(BLACK); //注湯する重量の設定
M5.Lcd.setTextSize(3);
M5.Lcd.setTextColor(WHITE, BLACK);
M5.Lcd.setCursor(0, 10);
M5.Lcd.print("Coffee Scale 01\n");
M5.Lcd.setCursor(0, 50);
M5.Lcd.print("Set Coffee Grams.\n");
M5.Lcd.setCursor(30,210);
M5.Lcd.print("+100 +10 SET");
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(190,75);
M5.Lcd.print("(max 500g)");
M5.Lcd.setTextColor(TFT_RED, BLACK);
while(targetSet == false){
M5.update();
if (M5.BtnA.wasReleased()){
if (target <= 400){target += 100;}//最大値は500g 超えるとゼロに戻る
else {target = 0;}}
if (M5.BtnB.wasReleased()){
if (target <= 490){target += 10;}//調整は10g単位
else {target = 0;}}
if (M5.BtnC.wasReleased() || M5.BtnC.wasPressed()){targetSet = true;}//セット完了
M5.Lcd.setCursor(50,100);
M5.Lcd.setTextSize(5);
M5.Lcd.print(target);M5.Lcd.print("g ");
}
//タイマー関連
samplingTimer = timerBegin(TIMER0, 80, true); // 分周比80、1μ秒のタイマを作る
timerAttachInterrupt(samplingTimer, &onTimer0, true); // タイマ割込みハンドラを指定
scaleDrp.begin(DATDrp, CLKDrp);//スケール初期化
scaleCup.begin(DATCup, CLKCup);
scaleDrp.set_scale(447.5f); // this value is obtained by calibrating the scale with known weights; see the README for details
scaleCup.set_scale(446.0f); // ここの値は実際に計測してみて決定
delay(10);
scaleDrp.tare();// スケールをゼロにリセット
scaleCup.tare();
delay(500);
M5.Lcd.clear(BLACK);
}
void loop() {
measureWeight(5);//5回計測して平均
if ((weightDrp>weightDrpPrev+500)||(weightDrp<weightDrpPrev-500)){weightDrp=weightDrpPrev;}//稀に大きく外れた値が測定される
if ((weightCup>weightCupPrev+500)||(weightCup<weightCupPrev-500)){weightCup=weightCupPrev;}//500g以上変動した値は捨てる
if (weightDrp > weightDrpMax){weightDrpMax = weightDrp;}//最大値更新
if (weightCup > weightCupMax){weightCupMax = weightCup;}
weightDrpAvg = (weightDrpPrev + weightDrp)/2;//表示は前回測定と今回測定の平均
weightCupAvg = (weightCupPrev + weightCup)/2;//初回はPrevが0だが気にしないことにする
weightDrpDisp = (float)((int)((weightDrpAvg+0.25) * 2 )) / 2;//表示は0.5g単位とする
weightCupDisp = (float)((int)((weightCupAvg+0.25) * 2)) / 2;
Serial.print(weightDrp);
Serial.print(",");
Serial.println(weightCup);
//ドリッパーかカップが最大値より50g以上軽くなったら終了
if ( ((weightDrp < weightDrpMax - 50)||(weightCup < weightCupMax - 50)) && measuring == true && measureEnded == false)
{
measureEnded = true;
measuring = false;
toneNum = 3; toneStep = 0;//4曲目の最初の音から
toneStart(myTone[toneNum][toneStep].tone,myTone[toneNum][toneStep].duration);//計測終了
}
if (toneOff == true && measureEnded == true){//終了の音が終わっていることを確認
drawGraph();//グラフ表示
savingData();//終了処理へ
M5.Lcd.print("syuuryou");
while(1);
}
weightDrpPrev = weightDrp;//次回の平均算出用に今回の値を保存
weightCupPrev = weightCup;
if (weightDrp > 1 && measuring==false && measureEnded == false){//1g増えたら計測開始
measuring = true;
startMillis = millis();
toneNum = 0; toneStep = 0;//0曲目の最初の音から
toneStart(myTone[toneNum][toneStep].tone,myTone[toneNum][toneStep].duration);//計測開始音をならす
}
if (measuring){ //表示する分と秒の値
minute = (millis() - startMillis) / 60000;
sec = (millis() - startMillis - minute*60*1000 ) / 1000;
}
//注湯量がレシピ指定の量を超えたら、レシピを1ステップ進める。ステップが最後までいったらそこでストップ。
if ((weightCupAvg + weightDrpAvg > target * recipe[recipeCount]/100) & (recipeCount < recipeSteps-1))
{recipeCount++;
toneNum = 1; toneStep = 0;//2曲目の最初の音から
toneStart(myTone[toneNum][toneStep].tone,myTone[toneNum][toneStep].duration);//レシピのステップが進んだ音
}
//注湯量がレシピ指定の最後に到達
if ((weightCupAvg + weightDrpAvg > target * recipe[recipeSteps-1]/100) && targetReached == false)
{targetReached = true; // 音を出し続けないための処理
toneNum = 2; toneStep = 0;//3曲目の最初の音から
toneStart(myTone[toneNum][toneStep].tone,myTone[toneNum][toneStep].duration);//レシピの最後に到達した音
}
M5.Lcd.setTextSize(3);
M5.Lcd.setTextColor(TFT_WHITE, TFT_BLACK);
M5.Lcd.setCursor(0,0);
M5.Lcd.printf("%02d:%02d ",minute, sec);//分と秒を表示
M5.Lcd.setCursor(30,100);
M5.Lcd.printf("Total %4.1f g ",weightDrpDisp + weightCupDisp);//トータルの注湯量 0.5g単位
M5.Lcd.setCursor(30,130);
M5.Lcd.printf("Dripper %4.1f g ",weightDrpDisp);//ドリッパーにある量 0.5g単位
M5.Lcd.setCursor(30,160);
M5.Lcd.printf("Cup %4.1f g ",weightCupDisp);//カップに落ちた量 0.5g単位
M5.Lcd.setCursor(30,190);
if (measuring == true && measureEnded == false && secCounter>2){
M5.Lcd.printf("Speed %4.1f g/s ",weightCupData[secCounter - 1 ] - weightCupData[secCounter - 2]);//抽出スピード ドリップ中のみ表示
}
else{
M5.Lcd.printf("Speed ---- ");
}
M5.Lcd.setCursor(30,30);
M5.Lcd.setTextSize(5);
M5.Lcd.setTextColor(TFT_YELLOW, TFT_BLACK);
M5.Lcd.print(target * recipe[recipeCount] / 100);//レシピのターゲット量を表示
gap = (target * recipe[recipeCount] / 100) - (weightDrpDisp + weightCupDisp);//ターゲットまであと何グラムか
M5.Lcd.setTextColor(TFT_RED, TFT_BLACK);
M5.Lcd.setCursor(155,30); //桁を合わせてgapを表示
if (0<= gap && gap<10){M5.Lcd.print(" ");}//gapは1桁
if (-9 <= gap && gap<100){M5.Lcd.print(" ");}//1-2桁
if (-99 <= gap ){M5.Lcd.print(" ");}//1-3桁 -100以下は4桁
M5.Lcd.print(gap);
M5.Lcd.setTextSize(2);
M5.Lcd.setTextColor(TFT_WHITE, TFT_OLIVE);
M5.Lcd.setCursor(30,75);
M5.Lcd.print("Target");
M5.Lcd.setCursor(200,75);
M5.Lcd.print("To Go");
//前回データ保存から1秒以上経っていたらデータを保存
if ((measuring) && (millis() > dataSavedMillis + 1000)){
weightDrpData[secCounter] = weightDrp;
weightCupData[secCounter] = weightCup;
targetData[secCounter] = target * recipe[recipeCount] / 100;
secCounter++;
dataSavedMillis = millis();
if (secCounter>=300){measureEnded = true;measuring = false;}//5分経過で計測終了する
}
weightPrev = weightCupAvg;//
if(toneOff==false){//音が出ている
if (millis()>toneEndMillis){//次の音に進む
toneStep++;
if(toneStep>1){//曲の最後
timerAlarmDisable(samplingTimer); // タイマを止めて音を消す
toneOff = true;
}
else {
toneStart(myTone[toneNum][toneStep].tone, myTone[toneNum][toneStep].duration);
}
}
}
}
Google Spreadsheetに保存
測定データをGoogle Spreadsheetに保存するためには、データを受け取るGoogle側の設定が必要となります。過去に作成した動画をご参照ください。
保存された抽出データの例です。黄色がレシピによるターゲット、緑が注いだお湯の量、青がドリッパー内、赤がコーヒーカップ内のお湯の量です。
もちろん、データ保存を行わなくても、ドリップスケールとしての使用は可能です。
ちゃんと動きました
コーヒーをドリップしてみた動画がこちら。
こんな機能があります。
- 抽出するお湯の量は500gまで設定可能
- ドリッパーにお湯を注ぐと、音が出て時計が動き始める
- ドリッパーとカップのそれぞれの重量を測定して、表示・記録
- レシピで設定したお湯の量を表示し、注湯すると、あと何グラム注げばよいかをカウントダウン
- カップの重量の増え方から、流速も計算して表示
- 設定湯量になると音が出て知らせます
- 抽出終了して、ドリッパーかカップを外すと、測定データをグラフ表示
- Saveボタンを押すと、Google Spreadsheetに自動保存
- 保存時のファイル名、シート名は、それぞれ年月日、時分秒を使用するので、あとでデータの整理が楽
まずは動きました。レシピはプログラム中に記述してある1種類だし、UIもいまいちですが、少しずつ改良していきましょう。
投稿者の人気記事
-
toolware
さんが
2021/02/28
に
編集
をしました。
(メッセージ: 初版)
-
toolware
さんが
2021/02/28
に
編集
をしました。
(メッセージ: HX711モジュールの接続方法を修正)
-
toolware
さんが
2021/02/28
に
編集
をしました。
(メッセージ: HX711モジュールの名称を修正)
-
toolware
さんが
2021/02/28
に
編集
をしました。
(メッセージ: 抽出データの例を追加)
-
toolware
さんが
2021/02/28
に
編集
をしました。
(メッセージ: 画像を追加)
-
toolware
さんが
2021/02/28
に
編集
をしました。
(メッセージ: 重複する写真を削除)
ログインしてコメントを投稿する