連休を使って ゲーム作成(前回)の続きを進めようと思っていたが、色々思いついたことをAIへ相談しているうちに家庭内Dashboardを作成することにした。最初は家計簿情報の有効活用として支出状況を共有しようとした際にNASが使えないか調べたところ、使用しているSynology製品のWeb機能を使って思ったよりも簡単に構築することが出来た。
| ChatGPTで生成 (今回の作業アシスタントGeminiが画像生成だけ調子が悪かったため、この画像だけChatGPT使用) |
使用したツール・環境
- Excel
- Python
- Synology DS218Play(NAS)
- SwitchBot(温度計)
Excelダッシュボード作成
Excel Dashboard
家計簿データをExcelのグラフ&テキストボックスの組み合わせで作成。Power BIの使用やJavaScript等でもっと上手くWebページに表示させる方法もありそうだが、ひとまずDashboardの形を手っ取り早く作ることを優先。
| Excelのグラフとテキストボックスのみで作成 |
Dashboardの画像化
作成したDashboardが含まれるセルを選択した状態にして、通常はセル番号が表示されている「名前ボックス」に名称を入力。今回は支出ダッシュボードということで「ExpDashboard」という名称にした。VBAが画像を生成する際に場所を指定するために使用する。| セル番号の代わりにこの名称で範囲指定できる |
ThisWorkbookへ記述:
Private Sub Workbook_AfterSave(ByVal Success As Boolean)
If Not Success Then Exit Sub
Dim ws As Worksheet: Set ws = ThisWorkbook.Sheets("Dashboard")
Dim currentSheet As Worksheet: Set currentSheet = ActiveSheet ' 今のシートを記憶
Dim currentYear As String: currentYear = Format(Date, "yyyy")
ipaddr = "xxx.xxx.xxx.xxx"
' --- 開始の合図 ---
Application.DisplayStatusBar = True
Application.StatusBar = " NASポータル更新中...(クエリ更新・画像出力)"
' --- 1. 実行判定 ---
' ファイル名に「現在の年」が含まれていないなら終了
If InStr(ThisWorkbook.Name, currentYear) = 0 Then Exit Sub
' Z2ストッパー(ボタン実行時)なら、合図を消して終了
If ws.Range("Z2").Value <> "" Then
ws.Range("Z2").ClearContents
Exit Sub
End If
' --- 1. クエリ更新 ---
' クエリのバックグラウンド更新はOFFにしておくこと
Application.StatusBar = "データを最新に更新しています..."
On Error Resume Next
ThisWorkbook.RefreshAll
DoEvents ' 描画を一度許容してステータスバーを反映
On Error GoTo 0
' --- 2. ズーム制御とシート切り替え ---
Dim currentZoom As Integer
Application.ScreenUpdating = False
' Dashboardシートを一瞬だけアクティブにしてズーム設定
ws.Activate
currentZoom = ActiveWindow.Zoom ' Dashboardの元のズームを記憶
ActiveWindow.Zoom = 100 ' Dashboardを100%に
' --- 3. 画像出力 ---
Application.StatusBar = "NASへ画像を転送しています..."
' 日付セット
ws.Range("B1").Value = DateSerial(Year(Date), Month(Date), 1)
' 最新月(ExpDashboard.jpg)の出力
Dim mainPath As String
mainPath = "\\" & ipaddr & "\web\ExpDashboard.jpg"
ExportRangeToJpg ws.Range("ExpDashboard"), mainPath
' 年間集計(Year_2026.jpgなど)の自動出力
' ファイル名に基づいた年間アーカイブもついでに更新する
Dim yearlyPath As String
yearlyPath = "\\" & ipaddr & "\web\archive\" & currentYear & "_Total.jpg"
ExportRangeToJpg ws.Range("ExpDash_Year"), yearlyPath
' --- 4. 後片付け ---
ActiveWindow.Zoom = currentZoom ' Dashboardのズームを戻す
currentSheet.Activate ' 元々作業していたシートに戻る
' --- 完了の合図 ---
Application.StatusBar = "NASポータルの更新が完了しました!"
Application.ScreenUpdating = True
' 3秒後にステータスバーをクリアする
Application.OnTime Now + TimeValue("00:00:03"), "ClearStatusBar"
End Sub
' --- 汎用出力関数(コードをスッキリさせるため分離) ---
Sub ExportRangeToJpg(targetRange As Range, fullPath As String)
Dim chartObj As ChartObject
targetRange.CopyPicture Appearance:=xlScreen, Format:=xlPicture
DoEvents
Set chartObj = targetRange.Worksheet.ChartObjects.Add(0, 0, targetRange.Width, targetRange.Height)
With chartObj
.Select
.Chart.Paste
Dim startTime As Double: startTime = Timer
Do While Timer < startTime + 0.3: DoEvents: Loop
.Chart.Export fileName:=fullPath, FilterName:="JPG"
.Delete
End With
End Sub
標準モジュール.Module1: 月間ダッシュボード保存用関数
Sub save_monthly()
Dim ws As Worksheet: Set ws = ThisWorkbook.Sheets("Dashboard")
Dim targetRange As Range: Set targetRange = ws.Range("ExpDashboard")
Dim targetDate As Date
Dim fileName As String
Dim fullPath As String
Dim currentZoom As Integer
Dim chartObj As ChartObject
' 1. 保存する年月をB1セル等から取得(なければ入力させる)
On Error Resume Next
targetDate = ws.Range("B1").Value
On Error GoTo 0
If targetDate = 0 Or Not IsDate(targetDate) Then
Dim res As String
res = InputBox("保存する年月を入力してください(例:2026/2)", "月次アーカイブ保存")
If res = "" Or Not IsDate(res) Then Exit Sub
targetDate = CDate(res)
End If
' 2. 保存先パスの作成
fileName = Format(targetDate, "yyyy_mm") & ".jpg"
fullPath = "\\xxx.xxx.xxx.xxx\web\archive\" & fileName
' 3. 【重要】自動保存(ThisWorkbook側)をブロックする合図を書き込む
ws.Range("Z2").Value = "STOP"
' 4. 画像の書き出し処理
Application.ScreenUpdating = False
' ズームを100%に固定
currentZoom = ActiveWindow.Zoom
ActiveWindow.Zoom = 100
targetRange.CopyPicture Appearance:=xlScreen, Format:=xlPicture
DoEvents
Set chartObj = ws.ChartObjects.Add(0, 0, targetRange.Width, targetRange.Height)
With chartObj
.Select
.Chart.Paste
Dim startTime As Double: startTime = Timer
Do While Timer < startTime + 0.5: DoEvents: Loop
.Chart.Export fileName:=fullPath, FilterName:="JPG"
.Delete
End With
' ズームを元に戻す
ActiveWindow.Zoom = currentZoom
' 5. Excel自体を上書き保存(これによりZ2ストッパーが作動して最新版更新がスルーされる)
ThisWorkbook.Save
Application.ScreenUpdating = True
MsgBox fileName & " としてアーカイブ保存が完了しました!"
End Sub
標準モジュール.Module2: 年間集計ダッシュボード保存用関数
Sub save_year()
Dim ws As Worksheet: Set ws = ThisWorkbook.Sheets("Dashboard")
Dim targetRange As Range: Set targetRange = ws.Range("ExpDash_Year") ' 年間用範囲
Dim targetDate As Date
Dim fileName As String
Dim fullPath As String
Dim currentZoom As Integer
Dim chartObj As ChartObject
' 1. 保存する「年」をB1セル等から取得(なければ入力させる)
On Error Resume Next
targetDate = ws.Range("B1").Value
On Error GoTo 0
If targetDate = 0 Or Not IsDate(targetDate) Then
Dim res As String
res = InputBox("対象の年を入力してください(例:2025)", "年間アーカイブ保存")
' 数字のみ(2025)と入力された場合への対策
If IsNumeric(res) And Len(res) = 4 Then res = res & "/1/1"
If res = "" Or Not IsDate(res) Then Exit Sub
targetDate = CDate(res)
End If
' 2. 保存先パスの作成 (例: 2026_Total.jpg)
fileName = Format(targetDate, "yyyy") & "_Total.jpg"
fullPath = "\\xxx.xxx.xxx.xxx\web\archive\" & fileName
' 3. 【重要】自動保存(ThisWorkbook側)をブロックする合図を書き込む
ws.Range("Z2").Value = "ARCHIVING"
' 4. 画像の書き出し処理
Application.ScreenUpdating = False
' ズームを100%に固定
currentZoom = ActiveWindow.Zoom
ActiveWindow.Zoom = 100
' 年間用範囲をコピー
targetRange.CopyPicture Appearance:=xlScreen, Format:=xlPicture
DoEvents
' グラフを年間範囲のサイズで作成
Set chartObj = ws.ChartObjects.Add(0, 0, targetRange.Width, targetRange.Height)
With chartObj
.Select
.Chart.Paste
Dim startTime As Double: startTime = Timer
Do While Timer < startTime + 0.5: DoEvents: Loop
.Chart.Export fileName:=fullPath, FilterName:="JPG"
.Delete
End With
' ズームを元に戻す
ActiveWindow.Zoom = currentZoom
' 5. Excel自体を上書き保存(Z2ストッパー作動)
ThisWorkbook.Save
Application.ScreenUpdating = True
MsgBox fileName & " として年間アーカイブの保存が完了しました!"
End Sub
標準モジュール.Module3: ステータスバーのクリア関数
Sub ClearStatusBar()
Application.StatusBar = False ' ステータスバーをデフォルト(準備完了)に戻す
End Sub
Dashboard用の画像作成のためか以前よりExcelファイル動作が重くなった印象。入力するBookと画像処理用Bookに分けることや、そもそもDashboard作成を別のツールにするなど今後の課題。
最終的には使用する必要はなくなったが、Excelのカメラ機能を使うと指定範囲のライブ画像を作成することも可能。画像ではあるものの、保存するタイミングでの指定範囲の状態が反映される。
Synologyの設定
| エラーメッセージ的ではあるがまだセットアップ出来ていないよ的な内容 |
ページ作成
画像で作成したDashboardを並べて表示する。今年の各月分と、過去の年間集計(月別とほぼ同じ内容だが1年間の集計と月平均支出)を並べたレイアウト。expdashboard.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我が家の家計簿ポータル</title>
<style>
body { font-family: sans-serif; text-align: center; background: #f4f4f4; margin: 0; padding: 20px; }
.card { background: white; margin: 20px auto; padding: 15px; border-radius: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); max-width: 1000px; }
.img-link { display: inline-block; transition: opacity 0.2s; text-decoration: none; }
.img-link:hover { opacity: 0.8; }
img { max-width: 100%; height: auto; border: 1px solid #ddd; border-radius: 4px; }
h2 { color: #333; font-size: 1.2rem; }
h1 { font-size: 1.5rem; }
/* アーカイブ共通:横スクロールレイアウト */
.scroll-container {
display: flex;
overflow-x: auto;
padding-bottom: 15px;
gap: 15px;
justify-content: flex-start;
scrollbar-width: thin; /* Firefox用 */
}
/* スクロールバーのデザイン(Chrome/Edge/Safari) */
.scroll-container::-webkit-scrollbar { height: 8px; }
.scroll-container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 10px; }
.archive-item { flex: 0 0 auto; }
/* 月次画像の幅 */
.monthly-img { width: 300px; }
/* 年間画像の幅(少し大きめ) */
.yearly-img { width: 400px; }
.label { font-size: 0.85rem; color: #555; margin-top: 5px; font-weight: bold; }
</style>
<meta http-equiv="Cache-Control" content="no-cache">
</head>
<body>
<h1>🏠 支出管理ダッシュボード</h1>
<div class="card">
<h2>📊 最新の状況(今月・先月)</h2>
<a href="ExpDashboard.jpg" target="_blank" class="img-link">
<img src="ExpDashboard.jpg" alt="最新家計簿">
</a>
<p style="font-size: 0.8rem; color: #666;">(画像クリックで拡大)</p>
</div>
<div class="card">
<h2>🗓️ 先月までの実績(月次)</h2>
<div class="scroll-container">
<a href="archive/2026_01.jpg" target="_blank" class="archive-item"><img src="archive/2026_01.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/01</div></a>
<a href="archive/2026_02.jpg" target="_blank" class="archive-item"><img src="archive/2026_02.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/02</div></a>
<a href="archive/2026_03.jpg" target="_blank" class="archive-item"><img src="archive/2026_03.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/03</div></a>
<a href="archive/2026_04.jpg" target="_blank" class="archive-item"><img src="archive/2026_04.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/04</div></a>
<a href="archive/2026_05.jpg" target="_blank" class="archive-item"><img src="archive/2026_05.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/05</div></a>
<a href="archive/2026_06.jpg" target="_blank" class="archive-item"><img src="archive/2026_06.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/06</div></a>
<a href="archive/2026_07.jpg" target="_blank" class="archive-item"><img src="archive/2026_07.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/07</div></a>
<a href="archive/2026_08.jpg" target="_blank" class="archive-item"><img src="archive/2026_08.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/08</div></a>
<a href="archive/2026_09.jpg" target="_blank" class="archive-item"><img src="archive/2026_09.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/09</div></a>
<a href="archive/2026_10.jpg" target="_blank" class="archive-item"><img src="archive/2026_10.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/10</div></a>
<a href="archive/2026_11.jpg" target="_blank" class="archive-item"><img src="archive/2026_11.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/11</div></a>
<a href="archive/2026_12.jpg" target="_blank" class="archive-item"><img src="archive/2026_12.jpg" class="monthly-img" onerror="this.parentElement.style.display='none';"><div class="label">2026/12</div></a>
</div>
</div>
<div class="card">
<h2>🏆 過去の年間集計</h2>
<div class="scroll-container">
<a href="archive/2026_Total.jpg" target="_blank" class="archive-item">
<img src="archive/2026_Total.jpg" class="yearly-img" onerror="this.parentElement.style.display='none';">
<div class="label">2026年度(経過)</div>
</a>
<a href="archive/2025_Total.jpg" target="_blank" class="archive-item">
<img src="archive/2025_Total.jpg" class="yearly-img" onerror="this.parentElement.style.display='none';">
<div class="label">2025年度 合計</div>
</a>
<a href="archive/2024_Total.jpg" target="_blank" class="archive-item">
<img src="archive/2024_Total.jpg" class="yearly-img" onerror="this.parentElement.style.display='none';">
<div class="label">2024年度 合計</div>
</a>
</div>
</div>
<p style="color: #888; margin-top: 30px;">※自動更新</p>
</body>
</html>SwitchBotの温度取得
トークン取得
SwitchBotからデータを取得するため、トークンとクライアントシークレットという情報が必要。隠しコマンド的なやり方で、SwitchBotアプリで取得可能。
SwitchBotアプリ > 設定 > 基本データ画面のアプリバージョンを10回連打すると、『開発者向けオプション』メニュー登場
| 必要に応じてトークンのリセットもこの画面で可能 |
Synology側準備
Pythonスクリプトで上記のトークンを使用してSwitchBotから情報を取得する。このPythonスクリプトをSynology(NAS)ないで定期的に実行して温度情報を更新する。そのため、まずはSynology内にPythonをインストールする。
| パッケージセンターよりPythonを検索(今回はPython 3.9) |
PIP/Requestsのインストール
# pip を導入
/usr/bin/python3 -m ensurepip
/usr/bin/python3 -m pip install --upgrade pip
# requestsのインストール
/usr/bin/python3 -m pip install requests
温度・湿度取得
Switchbot_get_devices.py
import time
import hashlib
import hmac
import base64
import requests
import json
token = 'xxx'
secret = 'xxx'
def get_devices():
t = str(int(round(time.time() * 1000)))
nonce = ""
string_to_sign = '{}{}{}'.format(token, t, nonce)
string_to_sign = bytes(string_to_sign, 'utf-8')
secret_bytes = bytes(secret, 'utf-8')
sign = base64.b64encode(hmac.new(secret_bytes, msg=string_to_sign, digestmod=hashlib.sha256).digest())
headers = {
"Authorization": token,
"sign": str(sign, 'utf-8'),
"t": t,
"nonce": nonce,
"Content-Type": "application/json; charset=utf8"
}
response = requests.get("https://api.switch-bot.com/v1.1/devices", headers=headers)
print(json.dumps(response.json(), indent=4, ensure_ascii=False))
get_devices()- deviceName: SwitchBotアプリで付けた名前(例:「リビング温湿度計」)
- deviceType: 防水温湿度計など
- deviceId: ABC123456789 のような英数字
Switchbot_get_devices.py
import time
import hashlib
import hmac
import base64
import requests
import json
import os
# --- 設定 ---
TOKEN = 'xxx'
SECRET = 'xxx'
DEVICES = {
"outside": "Dxxxxxxxxxxx9",
"living": "Bxxxxxxxxxxx3",
"bedroom": "ExxxxxxxxxxxD"
}
# 保存先パス(Synologyの絶対パスを指定)
SAVE_PATH = '/volume1/web/switchbot_temp.json'
# Local
# SAVE_PATH = 'switchbot_temp.json'
def get_status(device_id, headers):
url = f"https://api.switch-bot.com/v1.1/devices/{device_id}/status"
try:
res = requests.get(url, headers=headers)
data = res.json()
if data.get("statusCode") == 100:
return data.get("body")
except:
return None
return None
def update_all_status():
t = str(int(round(time.time() * 1000)))
nonce = ""
string_to_sign = f'{TOKEN}{t}{nonce}'.encode('utf-8')
sign = base64.b64encode(hmac.new(SECRET.encode('utf-8'), msg=string_to_sign, digestmod=hashlib.sha256).digest())
headers = {
"Authorization": TOKEN,
"sign": str(sign, 'utf-8'),
"t": t,
"nonce": nonce,
"Content-Type": "application/json; charset=utf8"
}
results = {
"last_update": time.strftime("%Y/%m/%d %H:%M:%S"),
"devices": {}
}
for name, dev_id in DEVICES.items():
status = get_status(dev_id, headers)
if status:
results["devices"][name] = {
"temp": status.get("temperature"),
"humidity": status.get("humidity")
}
with open(SAVE_PATH, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=4)
if __name__ == "__main__":
update_all_status()トップページ
| Blog投稿作成段階のトップページ |
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Home Portal</title>
<style>
:root {
--bg-color: #f0f2f5;
--card-bg: #ffffff;
--accent-blue: #007bff;
--text-main: #1c1e21;
--text-sub: #606770;
--out-color: #28a745; /* 外気温用:緑 */
--liv-color: #fd7e14; /* リビング用:オレンジ */
--bed-color: #6f42c1; /* 寝室用:紫 */
}
body { font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif; text-align: center; background: var(--bg-color); margin: 0; padding: 40px 20px; }
.container { max-width: 650px; margin: 0 auto; }
h1 { color: var(--text-main); margin-bottom: 25px; }
/* --- 温度モニターエリア --- */
.sensor-panel { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 30px; }
.sensor-card { background: white; flex: 1; padding: 15px 5px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); border-top: 4px solid #ccc; }
.sensor-card.outside { border-top-color: var(--out-color); }
.sensor-card.living { border-top-color: var(--liv-color); }
.sensor-card.bedroom { border-top-color: var(--bed-color); }
.s-label { font-size: 0.75rem; color: var(--text-sub); font-weight: bold; margin-bottom: 5px; }
.s-val { font-size: 1.4rem; font-weight: bold; color: var(--text-main); }
.s-unit { font-size: 0.8rem; margin-left: 2px; }
.s-hum { font-size: 0.9rem; color: var(--accent-blue); margin-top: 3px; display: block; }
/* --- メニューカード --- */
.menu-card { background: white; display: block; text-decoration: none; margin-bottom: 15px; padding: 25px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); transition: transform 0.2s, box-shadow 0.2s; }
.menu-card:hover { transform: translateY(-3px); box-shadow: 0 6px 16px rgba(0,0,0,0.12); }
.icon { font-size: 2rem; margin-bottom: 8px; }
.title { font-size: 1.2rem; font-weight: bold; color: var(--accent-blue); }
.desc { font-size: 0.85rem; color: var(--text-sub); margin-top: 6px; }
.admin { background: #f8f9fa; margin-top: 20px; }
.admin .title { color: #4b4f56; }
#update-time { color: #90949c; font-size: 0.7rem; margin-top: -20px; margin-bottom: 30px; }
</style>
</head>
<body>
<div class="container">
<h1>🏠 Home Server Portal</h1>
<div class="sensor-panel">
<div class="sensor-card outside">
<div class="s-label">🌳 外気温</div>
<div class="s-val"><span id="out-t">--</span><span class="s-unit">°C</span></div>
<div class="s-hum">💧 <span id="out-h">--</span>%</div>
</div>
<div class="sensor-card living">
<div class="s-label">🏠 リビング</div>
<div class="s-val"><span id="liv-t">--</span><span class="s-unit">°C</span></div>
<div class="s-hum">💧 <span id="liv-h">--</span>%</div>
</div>
<div class="sensor-card bedroom">
<div class="s-label">🛏️ 寝室</div>
<div class="s-val"><span id="bed-t">--</span><span class="s-unit">°C</span></div>
<div class="s-hum">💧 <span id="bed-h">--</span>%</div>
</div>
</div>
<div id="update-time">Loading...</div>
<a href="expdashboard.html" class="menu-card">
<div class="icon">📊</div>
<div class="title">支出管理ダッシュボード</div>
<div class="desc">今月・先月の家計状況・電気代予測を確認します</div>
</a>
<a href="http://xxx.xxx.xxx.xxx:5000" class="menu-card admin">
<div class="icon">⚙️</div>
<div class="title">NAS システム管理</div>
<div class="desc">Synology DSM (管理者専用)</div>
</a>
<p style="color: #90949c; font-size: 0.8rem; margin-top: 40px;">Private Network Only</p>
</div>
<script>
async function fetchSensors() {
try {
// ファイル名はご指定の switchbot_temp.json
const res = await fetch('switchbot_temp.json?t=' + new Date().getTime());
const d = await res.json();
// 各要素への流し込み
document.getElementById('out-t').innerText = d.devices.outside.temp;
document.getElementById('out-h').innerText = d.devices.outside.humidity;
document.getElementById('liv-t').innerText = d.devices.living.temp;
document.getElementById('liv-h').innerText = d.devices.living.humidity;
document.getElementById('bed-t').innerText = d.devices.bedroom.temp;
document.getElementById('bed-h').innerText = d.devices.bedroom.humidity;
document.getElementById('update-time').innerText = "Sensor Last Update: " + d.last_update;
} catch (e) {
console.error("JSON Read Error", e);
document.getElementById('update-time').innerText = "Sensor Error";
}
}
fetchSensors();
setInterval(fetchSensors, 300000); // 5分おきに画面更新
</script>
</body>
</html>今回もAIアシスタントをフル活用して、初期コード生成とデザイン案作成はおまかせしたおかげで、ここまでおそらく累計8時間程度で作成できた。今回のBlog投稿は自分で文章を書いているため時間を要した。プログラム作成も文章作成も、自分の頭で考える時間が減りすぎないように注意したい。
