[Mac M2 Pro 12CPU, Sonoma 14.5, wxWidgets 3.2.5]
右側の浴室乾燥のところにも手を付けました。
とりあえずガワだけそれなりに整えます。
[Mac M2 Pro 12CPU, Sonoma 14.5, wxWidgets 3.2.5]
右側の浴室乾燥のところにも手を付けました。
とりあえずガワだけそれなりに整えます。
[Mac M2 Pro 12CPU, Sonoma 14.5, wxWidgets 3.2.5]
ボタンの角を丸くして枠を消しラベル色と背景色を指定できるRoundedButtonクラスをChatGPTに作成してもらいました。当然ヘッダファイルもさくっと作ってくれます。
FLTKを苦労して学んでいたころからは信じられない楽さです。もっともあのころにC++の基本やMakefileの作り方を習得していたからできることではあります。
#include "RoundedButton.h"
RoundedButton::RoundedButton(wxWindow* parent, wxWindowID id, const wxString& label, const wxPoint& pos, const wxSize& size, const wxColour& bgColor, const wxColour& labelColor)
: wxButton(parent, id, label, pos, size), m_bgColor(bgColor), m_labelColor(labelColor)
{
SetBackgroundStyle(wxBG_STYLE_PAINT);
Bind(wxEVT_PAINT, &RoundedButton::OnPaint, this);
}
void RoundedButton::OnPaint(wxPaintEvent& event)
{
wxAutoBufferedPaintDC dc(this);
wxSize size = GetSize();
wxRect rect(0, 0, size.x, size.y);
dc.SetBrush(wxBrush(m_bgColor));
dc.SetPen(*wxTRANSPARENT_PEN);
dc.DrawRoundedRectangle(rect, 10); // 角の半径を10に設定
dc.SetTextForeground(m_labelColor); // ラベルの色を設定
dc.DrawLabel(GetLabel(), rect, wxALIGN_CENTER);
}
// AUTOボタン
new RoundedButton(bottomPanel, wxID_ANY, "AUTO", wxPoint(100, 5), wxSize(60, 30), wxColour("#00FFFF"), wxColour("#C0C0C0")); // シアン背景、白ラベル
// OFFボタン
new RoundedButton(bottomPanel, wxID_ANY, "OFF", wxPoint(180, 5), wxSize(60, 30), wxColour("#FF00FF"), wxColour("#FFFFFF")); // マゼンタ背景、白ラベル
[Mac M2 Pro 12CPU, Sonoma 14.5, wxWidgets 3.2.5]
wxWidgetsのコードはFLTKとはまた違った独特のクセがあります。
ChatGPTにGUIのコードを書いてもらいました。Swiftと遜色ない洗練された外観です。
やはりGUIは座標を使うのが楽です。SwiftUIやJavaのSwingは書きにくくて苦手です。
#include <wx/wx.h>
#include <wx/slider.h>
#include <wx/stattext.h>
#include <wx/choice.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/image.h>
#include <wx/bitmap.h>
#include "functions.h"
class MyFrame : public wxFrame
{
public:
MyFrame() : wxFrame(NULL, wxID_ANY, "SwitchBot Manager", wxDefaultPosition, wxSize(600, 400))
{
// パネルを左右に分割
wxPanel* leftPanel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(300, 400));
wxPanel* rightPanel = new wxPanel(this, wxID_ANY, wxPoint(300, 0), wxSize(300, 400));
// 背景色の設定
leftPanel->SetBackgroundColour(wxColour("#00CED1"));
rightPanel->SetBackgroundColour(wxColour("#4B0082"));
// 下部のパネルを追加して背景色を設定
wxPanel* bottomPanel = new wxPanel(leftPanel, wxID_ANY, wxPoint(0, 100), wxSize(300, 300));
bottomPanel->SetBackgroundColour(wxColour("#008B8B"));
// CSVからのデータ読み込み
std::vector<double> data = ReadCsvData();
// 室温と湿度
new wxStaticText(leftPanel, wxID_ANY, "室温", wxPoint(10, 10), wxSize(80, 20));
new wxStaticText(leftPanel, wxID_ANY, wxString::Format("%.1f", data[0]), wxPoint(100, 10), wxSize(80, 20));
new wxStaticText(leftPanel, wxID_ANY, "湿度", wxPoint(10, 40), wxSize(80, 20));
new wxStaticText(leftPanel, wxID_ANY, wxString::Format("%.0f", data[1]), wxPoint(100, 40), wxSize(80, 20));
// 不快指数
new wxStaticText(leftPanel, wxID_ANY, "不快指数", wxPoint(10, 70), wxSize(80, 20));
new wxStaticText(leftPanel, wxID_ANY, wxString::Format("%.0f", data[2]), wxPoint(100, 70), wxSize(80, 20));
// エアコン
new wxStaticText(bottomPanel, wxID_ANY, "エアコン", wxPoint(10, 10), wxSize(80, 20));
// AUTOボタン
new wxButton(bottomPanel, wxID_ANY, "AUTO", wxPoint(100, 5), wxSize(60, 30));
// OFFボタン
new wxButton(bottomPanel, wxID_ANY, "OFF", wxPoint(180, 5), wxSize(60, 30));
// 動作温度
new wxStaticText(bottomPanel, wxID_ANY, "動作温度", wxPoint(10, 51), wxSize(72, 18));
wxSlider* operationTempSlider = new wxSlider(bottomPanel, wxID_ANY, 26, 26, 28, wxPoint(110, 50), wxSize(120, 20), wxSL_HORIZONTAL);
wxStaticText* operationTempDisplay = new wxStaticText(bottomPanel, wxID_ANY, "26", wxPoint(240, 50), wxSize(40, 20));
// 温度幅
new wxStaticText(bottomPanel, wxID_ANY, "温度幅", wxPoint(10, 81), wxSize(54, 18));
wxSlider* tempRangeSlider = new wxSlider(bottomPanel, wxID_ANY, 3, 1, 5, wxPoint(110, 80), wxSize(120, 20), wxSL_HORIZONTAL);
wxStaticText* tempRangeDisplay = new wxStaticText(bottomPanel, wxID_ANY, "0.3", wxPoint(240, 81), wxSize(40, 20));
// 設定温度
new wxStaticText(bottomPanel, wxID_ANY, "設定温度", wxPoint(10, 111), wxSize(72, 18));
wxSlider* setTempSlider = new wxSlider(bottomPanel, wxID_ANY, 22, 20, 25, wxPoint(110, 110), wxSize(120, 20), wxSL_HORIZONTAL);
wxStaticText* setTempDisplay = new wxStaticText(bottomPanel, wxID_ANY, "22", wxPoint(240, 110), wxSize(40, 20));
// ファン
new wxStaticText(bottomPanel, wxID_ANY, "ファン", wxPoint(10, 141), wxSize(53, 18));
wxArrayString fanChoices;
fanChoices.Add("Auto");
fanChoices.Add("Low");
fanChoices.Add("Medium");
fanChoices.Add("High");
new wxChoice(bottomPanel, wxID_ANY, wxPoint(110, 140), wxSize(80, 20), fanChoices);
// Event bindings
operationTempSlider->Bind(wxEVT_SLIDER, [operationTempDisplay](wxCommandEvent& event) {
operationTempDisplay->SetLabel(wxString::Format("%.1f", event.GetInt() / 10.0));
});
tempRangeSlider->Bind(wxEVT_SLIDER, [tempRangeDisplay](wxCommandEvent& event) {
tempRangeDisplay->SetLabel(wxString::Format("%.1f", event.GetInt() / 10.0));
});
setTempSlider->Bind(wxEVT_SLIDER, [setTempDisplay](wxCommandEvent& event) {
setTempDisplay->SetLabel(wxString::Format("%d", event.GetInt()));
});
}
};
class MyApp : public wxApp
{
public:
virtual bool OnInit()
{
MyFrame* frame = new MyFrame();
frame->Show(true);
return true;
}
};
wxIMPLEMENT_APP(MyApp);
[Mac M2 Pro 12CPU, Sonoma 14.5, wxWidgets 3.2.5]
wxWidgetsのHomebrewでの最新バージョンが3.2.3と遅れているのでGitHub最新の3.2.5を自分でビルドしました。zipではビルドできません。tar.bz2をダウンロードします。
# --with-libtiff=builtinを追加しないとエラーになる
mkdir build-cocoa-debug
cd build-cocoa-debug
../configure --enable-debug --with-libtiff=builtin
make
ビルドしたライブラリとインクルードは/usr/localにコピーしました。アプリのビルド時にwx/setup.hがないというエラーになりましたが、以下のパスにあったのでインクルードフォルダにコピーしました。
/wxWidgets-3.2.5/build-cocoa-debug/lib/wx/include/osx_cocoa-unicode-3.2/wx/setup.h
wxWidgetsを広く普及するためにも、せめてHomebrewに最新版を登録して欲しいところですが、人が足りないのかな。
Makefileは以下の通りです。
# コンパイラ
COMPILER = clang++
DEBUG = -g
# フラグ
CPPFLAGS = -D_FILE_OFFSET_BITS=64 -DWXUSINGDLL -D__WXMAC__ -D__WXOSX__ -D__WXOSX_COCOA__ -std=c++17
LDFLAGS = -framework IOKit -framework Carbon -framework Cocoa -framework QuartzCore -framework AudioToolbox -framework System -framework OpenGL -lwx_osx_cocoau_xrc-3.2 -lwx_osx_cocoau_html-3.2 -lwx_osx_cocoau_qa-3.2 -lwx_osx_cocoau_core-3.2 -lwx_baseu_xml-3.2 -lwx_baseu_net-3.2 -lwx_baseu-3.2 -lc++
# includeパス(-I)
INCLUDE = -I./include -I/Volumes/DATA_m1/code/cpp/mylib/include -I/usr/local/include/wxWidgets
# ライブラリ(-l)
LIBRARY0 =
# ライブラリパス(-L)
LIBRARY = -L/usr/local/lib/wxWidgets
# ソースファイル
SRCDIR = ./src
SRCS = $(shell find $(SRCDIR) -type f)
# オブジェクトファイル
OBJDIR = ./obj
OBJS = $(addprefix $(OBJDIR), $(patsubst ./src/%.cpp,/%.o,$(SRCS)))
# 実行ファイル
TARGETDIR = ./bin
TARGET = SwitchBotManager
# cppファイルからoファイル作成 $<:依存ファイル
$(OBJDIR)/%.o: $(SRCDIR)/%.cpp
$(COMPILER) $(CPPFLAGS) $(INCLUDE) $(DEBUG) -o $@ -c $<
# oファイルから実行ファイルとappファイル作成
$(TARGET): $(OBJS)
$(COMPILER) -o $(TARGETDIR)/$@ $(OBJS) $(LIBRARY0) $(LDFLAGS) $(LIBRARY)
mkdir -p $(TARGET).app/Contents/MacOS
mkdir -p $(TARGET).app/Contents/Resources
cp $(TARGETDIR)/$(TARGET) $(TARGET).app/Contents/MacOS/$(TARGET)
cp ./images/$(TARGET).icns $(TARGET).app/Contents/Resources
echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" > $(TARGET).app/Contents/Info.plist
echo "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">" >> $(TARGET).app/Contents/Info.plist
echo "<plist version=\"1.0\">" >> $(TARGET).app/Contents/Info.plist
echo "<dict>" >> $(TARGET).app/Contents/Info.plist
echo " <key>CFBundleExecutable</key>" >> $(TARGET).app/Contents/Info.plist
echo " <string>$(TARGET)</string>" >> $(TARGET).app/Contents/Info.plist
echo " <key>CFBundleIconFile</key>" >> $(TARGET).app/Contents/Info.plist
echo " <string>$(TARGET).icns</string>" >> $(TARGET).app/Contents/Info.plist
echo " <key>CFBundleIdentifier</key>" >> $(TARGET).app/Contents/Info.plist
echo " <string>com.yourcompany.$(TARGET)</string>" >> $(TARGET).app/Contents/Info.plist
echo " <key>CFBundleName</key>" >> $(TARGET).app/Contents/Info.plist
echo " <string>$(TARGET)</string>" >> $(TARGET).app/Contents/Info.plist
echo " <key>CFBundleVersion</key>" >> $(TARGET).app/Contents/Info.plist
echo " <string>1.0</string>" >> $(TARGET).app/Contents/Info.plist
echo "</dict>" >> $(TARGET).app/Contents/Info.plist
echo "</plist>" >> $(TARGET).app/Contents/Info.plist
# 全ソース強制コンパイル
.PHONY:all
all: clean $(TARGET)
# 全ファイル削除
.PHONY:clean
clean:
rm -rf $(OBJS) $(TARGETDIR)/$(TARGET) $(TARGET).app
[Mac M2 Pro 12CPU, Sonoma 14.5]
SwiftUIではNSOpenPanelやfileImporterでファイル選択しないとローカルファイルを読み込めないことが判明したため、C++に戻りwxWidgetsに挑戦することにしました。
以前wxWidgetsを扱った時は実行ファイルは作成できてもappファイル作成ができなかったのですが、今回はChatGPTに手伝ってもらいあっさりMakefileを完成させました。
ウィジェットのデザインはOS依存ですから、Swiftアプリ風の見た目になります。
次の記事
[Mac M2 Pro 12CPU, Sonoma 14.5]
開発経過をGUIで記録します。
[Mac M2 Pro 12CPU, Sonoma 14.5]
FLTKのスライダーが機能不足なのでSwiftUIに変えました。
デスクトップではウィジェットの位置を座標で決めていたため、SwiftUIでVStackやHStackを使って位置決めするのはとてもやりずらいです。iOSやwatchOSでは違和感がないのですが。
[Mac M2 Pro 12CPU, Sonoma 14.5]
SwitchBot管理アプリMac版の製作に着手しました。手順は以下の通りです。
1.Adobe XDでGUIをデザイン。座標とサイズを自作プラグインで抽出
2.ChatGPT用プロンプトを作成しレスポンスを得る
3.手直ししたC++コードをビルド
このコードを土台に肉付けしていきます。
FLTKアプリを作成します
C++コードを考えてください
ウィンドウのサイズは600*400
各ウィジェットの座標は以下の通り
{
"エアコングラフ": [15, 200, 270, 180],
"ファン選択": [110, 140, 60, 20],
"ファン": [10, 141, 53, 18],
"温度幅表示": [240, 81, 40, 20],
"温度幅スライダー": [110, 80, 120, 20],
"温度幅": [10, 81, 54, 18],
"動作温度表示": [240, 50, 40, 20],
"動作温度スライダー": [110, 50, 120, 20],
"動作温度": [10, 51, 72, 18],
"OFFボタン": [190, 10, 60, 30],
"AUTOボタン": [110, 10, 60, 30],
"エアコン": [10, 15, 80, 20],
"設定温度表示": [240, 110, 40, 20],
"設定温度スライダー": [110, 110, 120, 20],
"設定温度": [10, 111, 72, 18]
}
エアコン:Fl_Boxに"エアコン"を表示
AUTOボタン:ラベルが"AUTO"のFl_Button
OFFボタン:ラベルが"OFF"のFl_Button
動作温度:Fl_Boxに"動作温度"を表示
動作温度スライダー:Fl_Sliderを表示 26から28まで0.1きざみ
動作温度表示:Fl_Boxに動作温度スライダーの値を表示。
温度幅:Fl_Boxに"温度幅"を表示
温度幅スライダー:Fl_Sliderを表示 0.1から0.5まで0.1きざみ
温度幅表示:Fl_Boxに温度幅スライダーの値を表示。
設定温度:Fl_Boxに"設定温度"を表示
設定温度スライダー:Fl_Sliderを表示 20から25まで1.0きざみ
設定温度表示:Fl_Boxに設定温度スライダーの値を表示。
ファン:Fl_Boxに"ファン"を表示
ファン選択:ブルダウンで選択 # fan speed includes 1 (auto), 2 (low), 3 (medium), 4 (high);
エアコングラフ:pngファイルを表示
[Mac M2 Pro 12CPU, Sonoma 14.5]
Pythonでエアコンを操作してみました。
このスクリプトを応用すれば、温湿度計のデータと連携させて0.1度単位でトリガーを設定し、エアコンを操作できるようになります。
純正アプリでは最小で0.5度の温度幅ですが、0.1度まで狭くすることが理屈では可能です。
ただ、SwitchBot APIへのコール回数は1日10000回までになっています。毎秒測定なら1日86400回になるので、10秒に1回程度に減らす必要があります。cronを使えば毎分が最多で1440回です。
import os
import time
import json
import hashlib
import hmac
import base64
import uuid
import requests
import datetime
dir_name = "/SwitchBot/data"
device_id = "XXX"
token = 'XXX'
secret = 'XXX'
# エアコン設定
temperature = 26.5
mode = 2 # modes include 0/1 (auto), 2 (cool), 3 (dry), 4 (fan), 5 (heat);
fanspeed = 1 # fan speed includes 1 (auto), 2 (low), 3 (medium), 4 (high);
power_state = "on" # power state includes on and off
nonce = str(uuid.uuid4())
t = int(round(time.time() * 1000))
string_to_sign = "{}{}{}".format(token, t, nonce)
string_to_sign = bytes(string_to_sign, "utf-8")
secret = bytes(secret, "utf-8")
sign = base64.b64encode(
hmac.new(secret, msg=string_to_sign, digestmod=hashlib.sha256).digest()
)
HEADERS = {
"Authorization": token,
"Content-Type": "application/json",
"charset": "utf8",
"t": str(t),
"sign": str(sign, "utf-8"),
"nonce": nonce
}
# コマンド内容
command_body = {
"command": "setAll",
"parameter": f"{temperature},{mode},{fanspeed},{power_state}",
"commandType": "command"
}
response = requests.post(
f"https://api.switch-bot.com/v1.1/devices/{device_id}/commands",
headers=HEADERS,
data=json.dumps(command_body)
)
status = response.json()
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
response_file = f"{dir_name}/AirCon/status_{device_id}_{timestamp}.json"
with open(response_file, "w") as f:
json.dump(status, f, ensure_ascii=False, indent=4)
print("Success operate Air Conditioner")
[Mac M2 Pro 12CPU, Sonoma 14.5]
浴室暖房乾燥機で洗濯物を乾かす際の温湿度をモニタリングしました。途中何度か風呂場に素早く入って乾き具合をチェックしました。
絶対湿度33g/m3位で洗濯物が8割方乾いていたので回収し、残りは27g/m3まで下がった時点で完全に乾いていました。
製作するアプリでは絶対湿度33g/m3以下で最初の回収を促し、27g/m3以下で終了を合図するようにします。
物理的にボタンを押してくれるスマートスイッチがあれば、浴室のリモコンまで行かなくても乾燥を自動停止できます。
import os
import time
import json
import hashlib
import hmac
import base64
import uuid
import requests
import datetime
import pandas as pd
import matplotlib.pyplot as plt
from os.path import exists
import subprocess
import numpy as np
dir_name = "/SwitchBot/data"
device_id = "XXX"
token = 'XXX'
secret = 'XXX'
nonce = str(uuid.uuid4())
t = int(round(time.time() * 1000))
string_to_sign = "{}{}{}".format(token, t, nonce)
string_to_sign = bytes(string_to_sign, "utf-8")
secret = bytes(secret, "utf-8")
sign = base64.b64encode(
hmac.new(secret, msg=string_to_sign, digestmod=hashlib.sha256).digest()
)
apiHeader = {}
apiHeader["Authorization"] = token
apiHeader["Content-Type"] = "application/json"
apiHeader["charset"] = "utf8"
apiHeader["t"] = str(t)
apiHeader["sign"] = str(sign, "utf-8")
apiHeader["nonce"] = nonce
response = requests.get(
f"https://api.switch-bot.com/v1.1/devices/{device_id}/status",
headers=apiHeader,
)
devices = response.json()
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
response_file = f"{dir_name}/json/status_{device_id}_{timestamp}.json"
with open(response_file, "w") as f:
json.dump(devices, f, ensure_ascii=False, indent=4)
print("Success get device status.")
# CSVファイルを作成(データ行を追加する)
timestamp_date = datetime.datetime.now().strftime("%Y%m%d")
csv_file = f"{dir_name}/{timestamp_date}_{device_id}_data.csv"
# デバイスのステータスから必要なデータを抽出
temperature = devices['body']['temperature']
humidity = devices['body']['humidity']
battery = devices['body']['battery']
# 湿度が60%以下の場合に通知を送る(動作不可)
if humidity <= 60:
subprocess.run(['osascript', '-e', f'display notification "Humidity is {humidity}%" with title "Humidity Alert"'])
# 絶対湿度を計算
absolute_humidity = (6.112 * np.exp((17.67 * temperature) / (temperature + 243.5)) * humidity * 2.1674) / (273.15 + temperature)
# CSVファイルにデータを追加
data = {
'timestamp': [timestamp],
'temperature': [temperature],
'relative_humidity': [humidity],
'absolute_humidity': [absolute_humidity],
'battery': [battery]
}
df = pd.DataFrame(data)
if exists(csv_file):
df.to_csv(csv_file, mode='a', header=False, index=False)
else:
df.to_csv(csv_file, mode='w', header=True, index=False)
print("Success append data to CSV.")
# CSVファイルからグラフを作成する(上書き更新)
plot_file = f"{dir_name}/{timestamp_date}_{device_id}_data_plot.png"
# CSVファイルを読み込む
df = pd.read_csv(csv_file)
# タイムスタンプをdatetime型に変換
df['timestamp'] = pd.to_datetime(df['timestamp'], format='%Y%m%d%H%M%S')
# 絶対湿度の移動平均を計算(5分間の移動平均)
df.set_index('timestamp', inplace=True)
df['absolute_humidity_ma'] = df['absolute_humidity'].rolling('5T').mean()
# グラフを作成
fig, ax1 = plt.subplots(figsize=(10, 5))
ax1.set_xlabel('Timestamp')
ax1.set_ylabel('Temperature (°C)', color='tab:red')
ax1.plot(df.index, df['temperature'], label='Temperature', color='tab:red')
ax1.tick_params(axis='y', labelcolor='tab:red')
ax1.set_ylim(20, 50)
ax2 = ax1.twinx()
ax2.set_ylabel('Absolute Humidity (g/m³)', color='tab:blue')
ax2.plot(df.index, df['absolute_humidity_ma'], label='Absolute Humidity (5-min MA)', color='tab:blue')
ax2.tick_params(axis='y', labelcolor='tab:blue')
ax2.set_ylim(20, 60)
fig.tight_layout(rect=[0, 0, 1, 0.95]) # タイトルが切れないように調整
plt.title('Temperature and Absolute Humidity (5-min MA) over Time', pad=20)
plt.xticks(rotation=45)
ax1.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter('%H:%M:%S'))
# グラフを保存
plt.savefig(plot_file)
plt.close()
print("Success create plot.")