用 Web Bluetooth API 打造血壓記錄 Web App:從零到 Omron BLE 協定逆向工程
買了一台 Omron JPN616T 血壓計,裝置上有個藍牙按鈕,官方 App 可以同步資料,但資料被鎖在廠商的雲端——沒有開放 API、沒有匯出功能。身為開發者,自然想自己做一個。
這篇文章記錄整個過程:從研究 Omron 的 BLE 通訊協定,到用純前端(不需後端、不需 App)的方式,在瀏覽器裡直接讀取血壓計的量測記錄。
目錄
1. 技術選型
要在 Web 上讀取藍牙裝置,有三條路:
| 方案 | 優點 | 缺點 |
|---|---|---|
| Web Bluetooth API | 純瀏覽器,零安裝,程式碼簡單 | 僅支援 Chrome / Edge;需 HTTPS |
| 原生 App 橋接(Electron / Flutter) | 跨平台,支援 iOS | 需要安裝,開發成本高 |
| BLE 閘道器(Raspberry Pi + Node.js) | 自動同步,不依賴手機 | 需要額外硬體和伺服器 |
本文選擇 Web Bluetooth API,原因很簡單:個人用途、桌機 Chrome、能直接分享 HTML 檔案給家人使用。如果你需要支援 iOS 或多人共用,可以考慮後兩者。
2. Omron 的 BLE 協定
這是整個專案最有趣的部分。
多數 BLE 醫療裝置遵循藍牙 SIG 制定的 GATT Blood Pressure Profile(Service UUID 0x1810),資料格式標準、開放,瀏覽器原生支援。但 Omron 不是。
私有協定 UUID
透過 omblepy 的逆向工程成果,可以整理出 Omron 舊協定(JPN616T、HEM-7322T、HEM-7600T EVOLV 等機型)的關鍵 UUID:
父服務(Parent Service) ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b 解鎖 Characteristic(Unlock) b305b680-aee7-11e1-a730-0002a5d5c51b TX Channels(主機 → 裝置,下指令) db5b55e0-aee7-11e1-965e-0002a5d5c51b e0b8a060-aee7-11e1-92f4-0002a5d5c51b 0ae12b00-aee8-11e1-a192-0002a5d5c51b 10e1ba60-aee8-11e1-89e5-0002a5d5c51b RX Channels(裝置 → 主機,回傳資料) 49123040-aee8-11e1-a74d-0002a5d5c51b 4d0bf320-aee8-11e1-a0d9-0002a5d5c51b 5128ce60-aee8-11e1-b84b-0002a5d5c51b 560f1420-aee8-11e1-8184-0002a5d5c51b
通訊流程
1. 連接 GATT Server 2. 取得私有服務 3. 寫入配對金鑰(16 bytes)到 Unlock Characteristic 4. 訂閱四個 RX Channel 的 Notification 5. 向 TX Channel 寫入讀取指令 6. 接收並解析回傳的量測記錄
資料格式(byte 排列)
Omron 回傳的每筆記錄格式(部分型號略有差異):
Byte 0 : 指令類型 Byte 1-2 : 年份(Little-endian uint16) Byte 3 : 月 Byte 4 : 日 Byte 5 : 時 Byte 6 : 分 Byte 7 : 秒 Byte 8-9 : 收縮壓 mmHg(Little-endian uint16) Byte 10-11: 舒張壓 mmHg(Little-endian uint16) Byte 12-13: 心率 bpm(Little-endian uint16)
配對金鑰
Omron 使用 應用層加密,在標準 BLE 配對之外,還需要將一個 16-byte 金鑰寫入裝置的 Unlock Characteristic。omblepy 使用的預設金鑰是:
deadbeaf12341234deadbeaf12341234
這個金鑰在首次「配對」時寫入裝置的 EEPROM,之後每次連線都需要提供相同金鑰才能讀取資料。
注意:Omron HEM-7140T1 等較新機型使用
FE4A服務,協定不同,本文程式碼未涵蓋。
3. 開源參考專案
研究 Omron BLE 協定時,這些開源專案非常有幫助:
userx14/omblepy
Python CLI 工具,專門讀取 Omron BLE 血壓計的量測記錄。整個專案的 BLE 協定知識幾乎都來自這裡。關鍵檔案是 omblepy.py 和 deviceSpecific/ 目錄下各型號的驅動。
支援型號:HEM-7322T、HEM-7600T(EVOLV)、HEM-7361T 等。
eigger/hass-omron
Home Assistant 的 Omron BLE 整合,同樣以 Python 實作,會自動輪詢並建立收縮壓、舒張壓、心率、袖帶貼合度等 HA 感測器實體。配對流程的 Python 實作可作為對照。
evnleong/open-BPM
Raspberry Pi + LoRaWAN 的血壓資料傳輸方案,支援 Omron BP7450(M7)和 EVOLV,需先用 omblepy 完成配對。
codeberg.org/LazyT/ubpm
Universal Blood Pressure Manager,桌面應用程式,支援 Windows / Linux / macOS,可以從多種血壓計(含 Omron BLE 裝置)匯入資料,並提供圖表、統計、SQL 查詢、CSV/JSON 匯出等功能。
4. 架構設計
這個 Web App 是一個單一 HTML 檔案,沒有框架、沒有建置工具,打開即用。
bp-tracker.html
├── CSS(設計系統,深色主題,CSS 變數)
├── HTML(四個頁面:總覽、記錄列表、手動輸入、藍牙下載)
└── JavaScript
├── 資料層(localStorage 持久化)
├── 圖表(Chart.js,趨勢折線圖)
├── 匯出入(CSV / JSON)
└── BLE 層
├── 標準 GATT Profile(0x1810)
└── Omron 私有協定(ecbe3980-…)
血壓分類依據 ACC/AHA 2017 標準:
| 分類 | 收縮壓 | 舒張壓 |
|---|---|---|
| 正常 | < 120 | < 80 |
| 偏高 | 120–129 | < 80 |
| 高血壓1期 | 130–139 | 80–89 |
| 高血壓2期 | ≥ 140 | ≥ 90 |
5. 完整原始碼
以下是完整的 bp-tracker.html,存成檔案後用 Chrome 開啟即可使用(藍牙功能需 HTTPS 或 localhost)。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>血壓記錄 · BP Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@300;400;500&family=Noto+Sans+TC:wght@300;400;500&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
:root {
--bg: #0f0e0d;
--bg2: #161513;
--bg3: #1e1c1a;
--border: #2e2b28;
--text: #e8e4df;
--text2: #8a847c;
--text3: #5a5550;
--accent: #c8a96e;
--accent2: #e8c98e;
--red: #d4645a;
--green: #6ab87a;
--blue: #6a9fd4;
--warn: #d4a05a;
--radius: 12px;
--radius-sm: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans TC', sans-serif;
font-weight: 300;
min-height: 100vh;
line-height: 1.6;
}
/* Layout */
.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
/* Sidebar */
.sidebar {
background: var(--bg2);
border-right: 1px solid var(--border);
padding: 32px 20px;
display: flex;
flex-direction: column;
gap: 8px;
position: sticky;
top: 0;
height: 100vh;
}
.logo {
font-family: 'DM Serif Display', serif;
font-size: 22px;
color: var(--accent);
letter-spacing: 0.02em;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
line-height: 1.2;
}
.logo span { display: block; font-size: 11px; font-family: 'DM Mono', monospace; color: var(--text3); letter-spacing: 0.1em; font-style: normal; margin-top: 4px; }
.nav-btn {
background: none;
border: none;
color: var(--text2);
font-family: 'Noto Sans TC', sans-serif;
font-size: 13px;
font-weight: 400;
padding: 10px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 0.02em;
}
.nav-btn:hover { background: var(--bg3); color: var(--text); }
.nav-btn.active { background: var(--bg3); color: var(--accent); border-left: 2px solid var(--accent); padding-left: 12px; }
.nav-btn .icon { font-size: 16px; width: 18px; text-align: center; }
.sidebar-bottom { margin-top: auto; }
.ble-status {
padding: 10px 14px;
background: var(--bg3);
border-radius: var(--radius-sm);
font-size: 11px;
font-family: 'DM Mono', monospace;
color: var(--text3);
display: flex;
align-items: center;
gap: 8px;
}
.ble-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--text3);
flex-shrink: 0;
transition: background 0.3s;
}
.ble-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
.ble-dot.connecting { background: var(--warn); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
/* Main content */
.main { overflow-y: auto; }
.page { display: none; padding: 40px 48px; }
.page.active { display: block; }
.page-header {
margin-bottom: 36px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.page-title {
font-family: 'DM Serif Display', serif;
font-size: 32px;
color: var(--text);
line-height: 1;
}
.page-subtitle { font-size: 12px; color: var(--text3); margin-top: 6px; font-family: 'DM Mono', monospace; letter-spacing: 0.08em; }
/* Cards */
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; }
.card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
}
.card-label { font-size: 11px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; margin-bottom: 8px; }
.card-value { font-family: 'DM Mono', monospace; font-size: 36px; font-weight: 500; color: var(--text); line-height: 1; }
.card-value .unit { font-size: 14px; color: var(--text2); margin-left: 4px; }
.card-sub { font-size: 11px; color: var(--text3); margin-top: 8px; }
.card-badge {
display: inline-block; padding: 2px 8px;
border-radius: 4px; font-size: 10px; font-weight: 500;
margin-top: 8px; font-family: 'DM Mono', monospace; letter-spacing: 0.05em;
}
.badge-normal { background: rgba(106,184,122,0.15); color: var(--green); }
.badge-elevated { background: rgba(212,160,90,0.15); color: var(--warn); }
.badge-high { background: rgba(212,100,90,0.15); color: var(--red); }
.badge-low { background: rgba(106,159,212,0.15); color: var(--blue); }
/* Chart */
.chart-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 24px;
}
.chart-title { font-size: 12px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; margin-bottom: 20px; }
.chart-wrap { position: relative; height: 220px; }
/* Table */
.table-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.table-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.table-header-title { font-size: 11px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; }
table { width: 100%; border-collapse: collapse; }
thead th {
padding: 12px 24px;
text-align: left;
font-size: 10px;
letter-spacing: 0.12em;
color: var(--text3);
font-family: 'DM Mono', monospace;
border-bottom: 1px solid var(--border);
font-weight: 400;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--bg3); }
tbody td { padding: 14px 24px; font-size: 13px; font-family: 'DM Mono', monospace; color: var(--text2); }
tbody td.val { color: var(--text); font-weight: 500; }
.row-high td.val { color: var(--red); }
/* Buttons */
.btn {
padding: 10px 20px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 12px;
letter-spacing: 0.05em;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary { background: var(--accent); color: #1a1510; font-weight: 500; }
.btn-primary:hover { background: var(--accent2); }
.btn-secondary { background: var(--bg3); color: var(--text2); border: 1px solid var(--border); }
.btn-secondary:hover { color: var(--text); border-color: var(--text3); }
.btn-danger { background: rgba(212,100,90,0.15); color: var(--red); border: 1px solid rgba(212,100,90,0.3); }
.btn-danger:hover { background: rgba(212,100,90,0.25); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* BLE Page */
.ble-device-panel {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
margin-bottom: 24px;
}
.ble-panel-title { font-family: 'DM Serif Display', serif; font-size: 20px; margin-bottom: 8px; }
.ble-panel-desc { font-size: 13px; color: var(--text2); margin-bottom: 28px; line-height: 1.7; }
.ble-steps { display: flex; flex-direction: column; gap: 16px; margin-bottom: 28px; }
.ble-step {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: var(--bg3);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.ble-step-num {
width: 28px; height: 28px; border-radius: 50%;
background: var(--accent);
color: #1a1510;
font-family: 'DM Mono', monospace;
font-size: 13px;
font-weight: 500;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.ble-step-text { font-size: 13px; color: var(--text2); line-height: 1.6; }
.ble-step-text strong { color: var(--text); font-weight: 500; }
.ble-log {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px;
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text3);
min-height: 100px;
max-height: 200px;
overflow-y: auto;
line-height: 1.8;
margin-top: 20px;
}
.ble-log .log-ok { color: var(--green); }
.ble-log .log-warn { color: var(--warn); }
.ble-log .log-err { color: var(--red); }
.ble-log .log-info { color: var(--blue); }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; }
/* Add record form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 10px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; }
.form-input {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.form-input:focus { border-color: var(--accent); }
/* Compat warning */
.compat-warn {
background: rgba(212,160,90,0.1);
border: 1px solid rgba(212,160,90,0.3);
border-radius: var(--radius-sm);
padding: 12px 16px;
font-size: 12px;
color: var(--warn);
font-family: 'DM Mono', monospace;
display: none;
margin-bottom: 20px;
line-height: 1.7;
}
/* Import panel */
.import-info {
background: rgba(106,159,212,0.08);
border: 1px solid rgba(106,159,212,0.25);
border-radius: var(--radius-sm);
padding: 16px;
font-size: 12px;
color: var(--blue);
font-family: 'DM Mono', monospace;
line-height: 1.8;
margin-bottom: 20px;
}
.divider { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* Empty state */
.empty-state { text-align: center; padding: 60px 24px; color: var(--text3); }
.empty-state .empty-icon { font-size: 40px; margin-bottom: 16px; opacity: 0.4; }
.empty-state p { font-size: 13px; line-height: 1.7; }
/* Toast */
.toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 20px;
font-size: 12px;
font-family: 'DM Mono', monospace;
color: var(--text);
z-index: 1000;
opacity: 0;
transform: translateY(8px);
transition: all 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { border-color: rgba(106,184,122,0.4); color: var(--green); }
.toast.error { border-color: rgba(212,100,90,0.4); color: var(--red); }
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="logo">
BP Tracker
<span>血壓記錄系統 v1.0</span>
</div>
<button class="nav-btn active" onclick="showPage('dashboard')" id="nav-dashboard">
<span class="icon">◈</span> 總覽
</button>
<button class="nav-btn" onclick="showPage('records')" id="nav-records">
<span class="icon">≡</span> 記錄列表
</button>
<button class="nav-btn" onclick="showPage('add')" id="nav-add">
<span class="icon">+</span> 手動輸入
</button>
<button class="nav-btn" onclick="showPage('bluetooth')" id="nav-bluetooth">
<span class="icon">⬡</span> 藍牙下載
</button>
<div class="sidebar-bottom">
<div class="ble-status">
<div class="ble-dot" id="ble-dot"></div>
<span id="ble-status-text">未連接</span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<!-- Dashboard -->
<div class="page active" id="page-dashboard">
<div class="page-header">
<div>
<div class="page-title">總覽</div>
<div class="page-subtitle">DASHBOARD · <span id="dash-count">0</span> 筆記錄</div>
</div>
<button class="btn btn-secondary" onclick="exportCSV()">⬇ 匯出 CSV</button>
</div>
<div class="cards">
<div class="card">
<div class="card-label">收縮壓 平均</div>
<div class="card-value" id="avg-sys">—<span class="unit">mmHg</span></div>
<div class="card-sub" id="avg-sys-sub">最近 7 天</div>
</div>
<div class="card">
<div class="card-label">舒張壓 平均</div>
<div class="card-value" id="avg-dia">—<span class="unit">mmHg</span></div>
<div class="card-sub" id="avg-dia-sub">最近 7 天</div>
</div>
<div class="card">
<div class="card-label">心率 平均</div>
<div class="card-value" id="avg-pulse">—<span class="unit">bpm</span></div>
<div class="card-sub" id="avg-pulse-sub">最近 7 天</div>
</div>
</div>
<div class="cards" style="grid-template-columns: repeat(2, 1fr);">
<div class="card">
<div class="card-label">最新量測</div>
<div class="card-value" id="latest-val">—</div>
<div class="card-sub" id="latest-time">尚無記錄</div>
<div id="latest-badge"></div>
</div>
<div class="card">
<div class="card-label">血壓分類 (ACC/AHA 2017)</div>
<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 6px; font-size: 11px; font-family: 'DM Mono', monospace; color: var(--text3);">
<div><span class="card-badge badge-normal">正常</span> <120/<80</div>
<div><span class="card-badge badge-elevated">偏高</span> 120-129/<80</div>
<div><span class="card-badge badge-high">高血壓1期</span> 130-139/80-89</div>
<div><span class="card-badge badge-high" style="background:rgba(212,100,90,0.25)">高血壓2期</span> ≥140/≥90</div>
</div>
</div>
</div>
<div class="chart-card">
<div class="chart-title">TREND · 趨勢圖(近 30 筆)</div>
<div class="chart-wrap">
<canvas id="bp-chart"></canvas>
</div>
</div>
</div>
<!-- Records -->
<div class="page" id="page-records">
<div class="page-header">
<div>
<div class="page-title">記錄列表</div>
<div class="page-subtitle">RECORDS · 全部量測資料</div>
</div>
<div style="display:flex; gap: 10px;">
<button class="btn btn-danger" onclick="clearAll()">✕ 清除全部</button>
<button class="btn btn-secondary" onclick="exportCSV()">⬇ 匯出 CSV</button>
</div>
</div>
<div class="table-card">
<div id="records-body"></div>
</div>
</div>
<!-- Add -->
<div class="page" id="page-add">
<div class="page-header">
<div>
<div class="page-title">手動輸入</div>
<div class="page-subtitle">MANUAL ENTRY · 新增量測記錄</div>
</div>
</div>
<div class="ble-device-panel">
<div class="form-grid">
<div class="form-group">
<label class="form-label">收縮壓 (mmHg)</label>
<input type="number" class="form-input" id="inp-sys" placeholder="120" min="60" max="250">
</div>
<div class="form-group">
<label class="form-label">舒張壓 (mmHg)</label>
<input type="number" class="form-input" id="inp-dia" placeholder="80" min="40" max="150">
</div>
<div class="form-group">
<label class="form-label">心率 (bpm)</label>
<input type="number" class="form-input" id="inp-pulse" placeholder="72" min="30" max="200">
</div>
<div class="form-group">
<label class="form-label">量測時間</label>
<input type="datetime-local" class="form-input" id="inp-time">
</div>
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label class="form-label">備注</label>
<input type="text" class="form-input" id="inp-note" placeholder="早晨量測、飯後 30 分鐘…">
</div>
<button class="btn btn-primary" onclick="addManual()">+ 新增記錄</button>
</div>
</div>
<!-- Bluetooth -->
<div class="page" id="page-bluetooth">
<div class="page-header">
<div>
<div class="page-title">藍牙下載</div>
<div class="page-subtitle">BLE DOWNLOAD · Omron 血壓計</div>
</div>
</div>
<div class="compat-warn" id="compat-warn">
⚠ 您的瀏覽器不支援 Web Bluetooth API。請使用 Chrome 或 Edge(桌面版)。<br>
iOS / Safari 用戶請參考下方替代方案。
</div>
<div class="ble-device-panel">
<div class="ble-panel-title">Omron 血壓計連線</div>
<div class="ble-panel-desc">
支援使用 Omron 私有 BLE 協定的機型,包含 JPN616T、HEM-7322T、HEM-7600T(EVOLV)等。<br>
首次配對需在血壓計上啟動配對模式,之後每次連線只需按下藍牙按鈕即可。
</div>
<div class="ble-steps">
<div class="ble-step">
<div class="ble-step-num">1</div>
<div class="ble-step-text">
長按血壓計上的藍牙按鈕,直到螢幕顯示閃爍的 <strong>-P-</strong>(配對模式)。<br>
<small>若已配對過,短按藍牙按鈕進入連線模式即可。</small>
</div>
</div>
<div class="ble-step">
<div class="ble-step-num">2</div>
<div class="ble-step-text">點擊下方「掃描裝置」,在瀏覽器彈窗中選擇您的血壓計(名稱通常為 <strong>BLEsmart</strong> 或 <strong>BP</strong> 開頭)。</div>
</div>
<div class="ble-step">
<div class="ble-step-num">3</div>
<div class="ble-step-text">連線成功後,點擊「下載資料」,系統將自動讀取裝置中儲存的所有量測記錄。</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-scan" onclick="bleScan()">⬡ 掃描裝置</button>
<button class="btn btn-secondary" id="btn-download" onclick="bleDownload()" disabled>⬇ 下載資料</button>
<button class="btn btn-secondary" id="btn-disconnect" onclick="bleDisconnect()" disabled>✕ 中斷連線</button>
</div>
<div class="ble-log" id="ble-log">等待操作…</div>
</div>
<hr class="divider">
<div class="ble-device-panel">
<div class="ble-panel-title" style="font-size: 16px;">匯入 JSON / CSV 檔案</div>
<div class="import-info">
ℹ 若使用 iOS / Safari,可先用 omblepy(Python)工具將資料匯出為 CSV 或 JSON,再用此功能匯入。<br>
CSV 格式:datetime, systolic, diastolic, pulse, note(第一行為表頭)
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="document.getElementById('file-input').click()">📂 選擇檔案</button>
<input type="file" id="file-input" accept=".csv,.json" style="display:none" onchange="importFile(this)">
</div>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
// ── Data ─────────────────────────────────────────────────────────────────────
let records = JSON.parse(localStorage.getItem('bp_records') || '[]');
let chart = null;
let bleDevice = null;
let bleServer = null;
function save() {
localStorage.setItem('bp_records', JSON.stringify(records));
}
// ── Navigation ────────────────────────────────────────────────────────────────
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
document.getElementById('nav-' + name).classList.add('active');
if (name === 'dashboard') renderDashboard();
if (name === 'records') renderRecords();
if (name === 'add') {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById('inp-time').value = now.toISOString().slice(0, 16);
}
}
// ── Classification ────────────────────────────────────────────────────────────
function classify(sys, dia) {
if (sys < 120 && dia < 80) return { label: '正常', cls: 'badge-normal' };
if (sys < 130 && dia < 80) return { label: '偏高', cls: 'badge-elevated' };
if (sys < 140 || dia < 90) return { label: '高血壓1期', cls: 'badge-high' };
return { label: '高血壓2期', cls: 'badge-high' };
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
function renderDashboard() {
const sorted = [...records].sort((a, b) => new Date(b.time) - new Date(a.time));
document.getElementById('dash-count').textContent = records.length;
if (sorted.length > 0) {
const r = sorted[0];
document.getElementById('latest-val').innerHTML =
`${r.sys}/${r.dia} <span class="unit">mmHg</span>`;
document.getElementById('latest-time').textContent =
new Date(r.time).toLocaleString('zh-TW');
const cat = classify(r.sys, r.dia);
document.getElementById('latest-badge').innerHTML =
`<div class="card-badge ${cat.cls}">${cat.label}</div>`;
}
const week = sorted.slice(0, 7);
if (week.length > 0) {
const avgSys = Math.round(week.reduce((s, r) => s + r.sys, 0) / week.length);
const avgDia = Math.round(week.reduce((s, r) => s + r.dia, 0) / week.length);
const avgPulse = Math.round(week.reduce((s, r) => s + r.pulse, 0) / week.length);
document.getElementById('avg-sys').innerHTML = `${avgSys}<span class="unit">mmHg</span>`;
document.getElementById('avg-dia').innerHTML = `${avgDia}<span class="unit">mmHg</span>`;
document.getElementById('avg-pulse').innerHTML = `${avgPulse}<span class="unit">bpm</span>`;
document.getElementById('avg-sys-sub').textContent = `最近 ${week.length} 筆`;
document.getElementById('avg-dia-sub').textContent = `最近 ${week.length} 筆`;
document.getElementById('avg-pulse-sub').textContent = `最近 ${week.length} 筆`;
}
const chartData = sorted.slice(0, 30).reverse();
const labels = chartData.map(r =>
new Date(r.time).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' }));
const ctx = document.getElementById('bp-chart').getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '收縮壓', data: chartData.map(r => r.sys),
borderColor: '#d4645a', backgroundColor: 'rgba(212,100,90,0.08)',
borderWidth: 2, pointRadius: 3, tension: 0.3 },
{ label: '舒張壓', data: chartData.map(r => r.dia),
borderColor: '#6a9fd4', backgroundColor: 'rgba(106,159,212,0.08)',
borderWidth: 2, pointRadius: 3, tension: 0.3 },
{ label: '心率', data: chartData.map(r => r.pulse),
borderColor: '#6ab87a', backgroundColor: 'rgba(106,184,122,0.08)',
borderWidth: 1.5, pointRadius: 2, tension: 0.3, borderDash: [4,3] }
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8a847c', font: { family: 'DM Mono', size: 11 }, boxWidth: 16 } },
tooltip: { backgroundColor: '#1e1c1a', titleColor: '#e8e4df', bodyColor: '#8a847c',
borderColor: '#2e2b28', borderWidth: 1 }
},
scales: {
x: { grid: { color: '#2e2b28' }, ticks: { color: '#5a5550', font: { family: 'DM Mono', size: 10 } } },
y: { grid: { color: '#2e2b28' }, ticks: { color: '#5a5550', font: { family: 'DM Mono', size: 10 } },
suggestedMin: 50, suggestedMax: 180 }
},
animation: { duration: 600 }
}
});
}
// ── Records ───────────────────────────────────────────────────────────────────
function renderRecords() {
const sorted = [...records].sort((a, b) => new Date(b.time) - new Date(a.time));
const el = document.getElementById('records-body');
if (sorted.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="empty-icon">◈</div>' +
'<p>尚無記錄<br>請手動輸入或連接藍牙血壓計下載資料</p></div>';
return;
}
let html = '<table><thead><tr><th>時間</th><th>收縮壓</th><th>舒張壓</th>' +
'<th>心率</th><th>分類</th><th>備注</th><th></th></tr></thead><tbody>';
sorted.forEach(r => {
const cat = classify(r.sys, r.dia);
const isHigh = r.sys >= 140 || r.dia >= 90;
html += `<tr class="${isHigh ? 'row-high' : ''}">
<td>${new Date(r.time).toLocaleString('zh-TW')}</td>
<td class="val">${r.sys}</td>
<td class="val">${r.dia}</td>
<td class="val">${r.pulse || '—'}</td>
<td><span class="card-badge ${cat.cls}">${cat.label}</span></td>
<td>${r.note || ''}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:11px"
onclick="deleteRecord(${r.id})">✕</button></td>
</tr>`;
});
html += '</tbody></table>';
el.innerHTML = html;
}
function deleteRecord(id) {
records = records.filter(r => r.id !== id);
save(); renderRecords(); toast('記錄已刪除');
}
function clearAll() {
if (!confirm('確定要清除全部記錄嗎?此操作無法復原。')) return;
records = []; save(); renderRecords(); toast('已清除全部記錄');
}
// ── Add Manual ────────────────────────────────────────────────────────────────
function addManual() {
const sys = parseInt(document.getElementById('inp-sys').value);
const dia = parseInt(document.getElementById('inp-dia').value);
const pulse = parseInt(document.getElementById('inp-pulse').value) || 0;
const time = document.getElementById('inp-time').value;
const note = document.getElementById('inp-note').value.trim();
if (!sys || !dia || !time) { toast('請填寫收縮壓、舒張壓與時間', 'error'); return; }
if (sys < 60 || sys > 250 || dia < 40 || dia > 150) { toast('數值超出合理範圍', 'error'); return; }
addRecord({ sys, dia, pulse, time, note });
document.getElementById('inp-sys').value = '';
document.getElementById('inp-dia').value = '';
document.getElementById('inp-pulse').value = '';
document.getElementById('inp-note').value = '';
toast('記錄已新增', 'success');
}
function addRecord(r) {
r.id = Date.now() + Math.random();
records.push(r);
save();
}
// ── Export / Import ───────────────────────────────────────────────────────────
function exportCSV() {
if (records.length === 0) { toast('沒有資料可匯出', 'error'); return; }
const sorted = [...records].sort((a, b) => new Date(a.time) - new Date(b.time));
let csv = 'datetime,systolic,diastolic,pulse,note\n';
sorted.forEach(r => {
csv += `"${r.time}",${r.sys},${r.dia},${r.pulse || ''},"${r.note || ''}"\n`;
});
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `bp_records_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
toast('CSV 已匯出', 'success');
}
function importFile(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
if (file.name.endsWith('.json')) importJSON(e.target.result);
else importCSVText(e.target.result);
input.value = '';
};
reader.readAsText(file, 'utf-8');
}
function importJSON(text) {
try {
const data = JSON.parse(text);
const arr = Array.isArray(data) ? data : data.records || [];
let count = 0;
arr.forEach(r => {
if (r.systolic && r.diastolic) {
addRecord({ sys: r.systolic, dia: r.diastolic,
pulse: r.pulse || r.heartRate || 0,
time: r.datetime || r.time || new Date().toISOString(),
note: r.note || '' });
count++;
}
});
toast(`已匯入 ${count} 筆記錄`, 'success');
} catch { toast('JSON 格式錯誤', 'error'); }
}
function importCSVText(text) {
const lines = text.replace(/^\uFEFF/, '').trim().split('\n');
let count = 0;
const header = lines[0].toLowerCase().split(',');
const idx = {
time: header.findIndex(h => h.includes('datetime') || h.includes('time')),
sys: header.findIndex(h => h.includes('systolic')),
dia: header.findIndex(h => h.includes('diastolic')),
pulse: header.findIndex(h => h.includes('pulse')),
note: header.findIndex(h => h.includes('note')),
};
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.replace(/"/g, '').trim());
const sys = parseInt(cols[idx.sys]);
const dia = parseInt(cols[idx.dia]);
if (!sys || !dia) continue;
addRecord({ sys, dia, pulse: parseInt(cols[idx.pulse]) || 0,
time: cols[idx.time] || new Date().toISOString(),
note: cols[idx.note] || '' });
count++;
}
toast(`已匯入 ${count} 筆記錄`, 'success');
}
// ── BLE ───────────────────────────────────────────────────────────────────────
// Omron 私有 BLE 協定(逆向自 omblepy / hass-omron)
const OMRON_SERVICE_UUID = 'ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b';
const OMRON_UNLOCK_UUID = 'b305b680-aee7-11e1-a730-0002a5d5c51b';
const OMRON_TX_UUIDS = [
'db5b55e0-aee7-11e1-965e-0002a5d5c51b',
'e0b8a060-aee7-11e1-92f4-0002a5d5c51b',
'0ae12b00-aee8-11e1-a192-0002a5d5c51b',
'10e1ba60-aee8-11e1-89e5-0002a5d5c51b',
];
const OMRON_RX_UUIDS = [
'49123040-aee8-11e1-a74d-0002a5d5c51b',
'4d0bf320-aee8-11e1-a0d9-0002a5d5c51b',
'5128ce60-aee8-11e1-b84b-0002a5d5c51b',
'560f1420-aee8-11e1-8184-0002a5d5c51b',
];
// 配對金鑰(與 omblepy 預設相同)
const PAIRING_KEY = new Uint8Array([
0xde,0xad,0xbe,0xaf,0x12,0x34,0x12,0x34,
0xde,0xad,0xbe,0xaf,0x12,0x34,0x12,0x34
]);
function bleLog(msg, type = '') {
const el = document.getElementById('ble-log');
const span = document.createElement('span');
span.className = type ? `log-${type}` : '';
span.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n`;
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function setBleState(state) {
const dot = document.getElementById('ble-dot');
const txt = document.getElementById('ble-status-text');
dot.className = 'ble-dot ' + (state === 'connected' ? 'connected'
: state === 'connecting' ? 'connecting' : '');
txt.textContent = state === 'connected' ? '已連接'
: state === 'connecting' ? '連接中…' : '未連接';
document.getElementById('btn-download').disabled = state !== 'connected';
document.getElementById('btn-disconnect').disabled = state !== 'connected';
document.getElementById('btn-scan').disabled = state === 'connecting';
}
async function bleScan() {
if (!navigator.bluetooth) { toast('此瀏覽器不支援 Web Bluetooth', 'error'); return; }
try {
setBleState('connecting');
bleLog('掃描藍牙裝置中…', 'info');
bleDevice = await navigator.bluetooth.requestDevice({
filters: [
{ namePrefix: 'BLEsmart' }, { namePrefix: 'BP' }, { namePrefix: 'HEM' },
{ services: [OMRON_SERVICE_UUID] }, { services: ['blood_pressure'] },
],
optionalServices: [OMRON_SERVICE_UUID, 'blood_pressure', 'device_information', 'current_time']
});
bleLog(`找到裝置:${bleDevice.name}`, 'ok');
bleDevice.addEventListener('gattserverdisconnected', onBleDisconnect);
bleLog('正在連接 GATT 伺服器…', 'info');
bleServer = await bleDevice.gatt.connect();
bleLog('GATT 連接成功', 'ok');
setBleState('connected');
toast('藍牙連接成功', 'success');
} catch (err) {
setBleState('');
bleDevice = null; bleServer = null;
if (err.name === 'NotFoundError') bleLog('使用者取消選擇', 'warn');
else bleLog(`連接失敗:${err.message}`, 'err');
}
}
async function bleDownload() {
if (!bleServer || !bleServer.connected) { toast('裝置未連接', 'error'); return; }
try {
bleLog('探索 GATT 服務…', 'info');
const services = await bleServer.getPrimaryServices();
const uuids = services.map(s => s.uuid);
bleLog(`發現服務:${uuids.join(', ')}`, 'info');
if (uuids.includes('00001810-0000-1000-8000-00805f9b34fb'))
await downloadStandardBP(bleServer);
else if (uuids.includes(OMRON_SERVICE_UUID))
await downloadOmronPrivate(bleServer);
else {
bleLog('未知協定,進入偵錯模式…', 'warn');
await scanAllCharacteristics(bleServer, services);
}
} catch (err) { bleLog(`下載失敗:${err.message}`, 'err'); }
}
// 標準 GATT Blood Pressure Profile
async function downloadStandardBP(server) {
bleLog('使用標準 Blood Pressure GATT Profile…', 'info');
const service = await server.getPrimaryService('blood_pressure');
const char = await service.getCharacteristic('blood_pressure_measurement');
return new Promise(async (resolve) => {
let count = 0;
char.addEventListener('characteristicvaluechanged', (event) => {
const r = parseStandardBP(event.target.value);
if (r) {
addRecord({ sys: r.sys, dia: r.dia, pulse: r.pulse,
time: new Date().toISOString(), note: '藍牙下載(GATT)' });
bleLog(`記錄:${r.sys}/${r.dia} mmHg, ♥ ${r.pulse} bpm`, 'ok');
count++;
}
});
await char.startNotifications();
bleLog('等待量測資料(Indication)…', 'info');
setTimeout(() => {
char.stopNotifications().catch(() => {});
bleLog(`完成,共下載 ${count} 筆`, 'ok');
if (count > 0) { renderRecords(); toast(`下載完成 ${count} 筆`, 'success'); }
resolve();
}, 30000);
});
}
// 解析標準 GATT 資料(IEEE 11073 sfloat)
function parseStandardBP(dataView) {
try {
const flags = dataView.getUint8(0);
const unit = (flags & 0x01) ? 'kPa' : 'mmHg';
function sfloat(dv, offset) {
const raw = dv.getUint16(offset, true);
const exp = raw >> 12; const mantissa = raw & 0x0FFF;
const e = exp >= 8 ? exp - 16 : exp;
const m = mantissa >= 0x800 ? mantissa - 0x1000 : mantissa;
return m * Math.pow(10, e);
}
const sys = Math.round(sfloat(dataView, 1));
const dia = Math.round(sfloat(dataView, 3));
const pulse = (flags & 0x04) ? Math.round(sfloat(dataView, 14)) : 0;
if (unit === 'kPa') return { sys: Math.round(sys * 7.50062), dia: Math.round(dia * 7.50062), pulse };
return { sys, dia, pulse };
} catch { return null; }
}
// Omron 私有協定(四通道 TX/RX)
async function downloadOmronPrivate(server) {
bleLog('使用 Omron 私有協定…', 'info');
const service = await server.getPrimaryService(OMRON_SERVICE_UUID);
// Step 1:寫入配對金鑰
try {
const unlockChar = await service.getCharacteristic(OMRON_UNLOCK_UUID);
await unlockChar.writeValue(PAIRING_KEY);
bleLog('解鎖金鑰寫入成功', 'ok');
} catch (e) { bleLog(`解鎖失敗(可能不需要):${e.message}`, 'warn'); }
// Step 2:訂閱 RX Channels
const rxChars = [];
for (const uuid of OMRON_RX_UUIDS) {
try { rxChars.push(await service.getCharacteristic(uuid)); } catch { }
}
bleLog(`找到 ${rxChars.length} 個 RX channel`, 'info');
let count = 0;
const received = [];
function onData(event) {
const data = new Uint8Array(event.target.value.buffer);
received.push(data);
const r = parseOmronRecord(data);
if (r) {
addRecord({ sys: r.sys, dia: r.dia, pulse: r.pulse, time: r.time, note: '藍牙下載(Omron)' });
bleLog(`記錄 ${r.time.slice(0,16)}:${r.sys}/${r.dia} mmHg, ♥ ${r.pulse} bpm`, 'ok');
count++;
}
}
for (const c of rxChars) { c.addEventListener('characteristicvaluechanged', onData); await c.startNotifications(); }
// Step 3:發送讀取指令
const txChars = [];
for (const uuid of OMRON_TX_UUIDS) {
try { txChars.push(await service.getCharacteristic(uuid)); } catch { }
}
if (txChars.length > 0) {
bleLog('發送資料讀取指令…', 'info');
const cmd = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]);
try { await txChars[0].writeValue(cmd); } catch (e) { bleLog(`指令發送失敗:${e.message}`, 'warn'); }
}
await new Promise(r => setTimeout(r, 8000));
for (const c of rxChars) {
try { await c.stopNotifications(); } catch { }
c.removeEventListener('characteristicvaluechanged', onData);
}
if (count > 0) { renderRecords(); toast(`下載完成,共 ${count} 筆`, 'success'); }
else {
bleLog('未解析到記錄,原始資料已存入 console', 'warn');
bleLog('提示:確認血壓計顯示 ■(已配對)而非 -P-', 'info');
console.log('原始 BLE 資料:', received);
}
}
// 解析 Omron 私有資料格式
function parseOmronRecord(data) {
if (data.length < 14) return null;
try {
const sys = data[8] | (data[9] << 8);
const dia = data[10] | (data[11] << 8);
const pulse = data[12] | (data[13] << 8);
if (sys < 60 || sys > 250 || dia < 40 || dia > 150) return null;
const year = data[1] | (data[2] << 8);
const time = (year > 2000 && year < 2100)
? new Date(year, data[3]-1, data[4], data[5], data[6], data[7]).toISOString()
: new Date().toISOString();
return { sys, dia, pulse, time };
} catch { return null; }
}
async function scanAllCharacteristics(server, services) {
for (const svc of services) {
const chars = await svc.getCharacteristics();
for (const c of chars) {
const p = c.properties;
bleLog(` ${c.uuid} [${p.read?'R':''}${p.write?'W':''}${p.indicate?'I':''}${p.notify?'N':''}]`, 'info');
}
}
}
function onBleDisconnect() { setBleState(''); bleServer = null; bleLog('裝置已中斷連線', 'warn'); }
function bleDisconnect() {
if (bleDevice && bleDevice.gatt.connected) bleDevice.gatt.disconnect();
setBleState(''); bleDevice = null; bleServer = null;
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function toast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast' + (type ? ` ${type}` : '');
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2800);
}
// ── Init ──────────────────────────────────────────────────────────────────────
if (!navigator.bluetooth) document.getElementById('compat-warn').style.display = 'block';
// 載入示範資料
if (records.length === 0) {
const now = Date.now();
[[128,82,68,0],[135,88,72,-1],[122,78,65,-2],[142,92,75,-3],[118,76,70,-4],
[130,85,68,-5],[125,80,72,-6],[138,90,76,-7],[119,77,64,-8],[132,84,70,-9]]
.forEach(([sys,dia,pulse,d]) => {
addRecord({ sys, dia, pulse, time: new Date(now + d*86400000).toISOString(), note: '示範資料' });
});
}
renderDashboard();
</script>
</body>
</html>
6. 程式碼解說
6.1 BLE 協定自動偵測
連線後,程式會先取得裝置所有 GATT 服務的 UUID 清單,再決定使用哪套協定:
async function bleDownload() {
const services = await bleServer.getPrimaryServices();
const uuids = services.map(s => s.uuid);
if (uuids.includes('00001810-0000-1000-8000-00805f9b34fb'))
await downloadStandardBP(bleServer); // 標準 GATT
else if (uuids.includes(OMRON_SERVICE_UUID))
await downloadOmronPrivate(bleServer); // Omron 私有
else
await scanAllCharacteristics(bleServer, services); // 偵錯模式
}
6.2 IEEE 11073 sfloat 解析
標準 GATT 血壓資料使用 IEEE 11073 的 16-bit sfloat 格式(不是一般的 float32),解析方式:
function sfloat(dv, offset) {
const raw = dv.getUint16(offset, true);
const exp = raw >> 12; // 高 4 bits = 指數
const mantissa = raw & 0x0FFF; // 低 12 bits = 尾數
const e = exp >= 8 ? exp - 16 : exp; // 帶符號指數
const m = mantissa >= 0x800 ? mantissa - 0x1000 : mantissa; // 帶符號尾數
return m * Math.pow(10, e);
}
6.3 Omron 資料 byte 解析
function parseOmronRecord(data) {
const sys = data[8] | (data[9] << 8); // Little-endian uint16
const dia = data[10] | (data[11] << 8);
const pulse = data[12] | (data[13] << 8);
const year = data[1] | (data[2] << 8);
const time = new Date(year, data[3]-1, data[4],
data[5], data[6], data[7]).toISOString();
return { sys, dia, pulse, time };
}
7. 踩坑紀錄與注意事項
Web Bluetooth 的瀏覽器限制
navigator.bluetooth 在以下環境不存在:Firefox(完全不支援)、Safari / iOS(不支援)、非 HTTPS 的網頁(localhost 除外)。
Omron 只能配對一台裝置
Omron 血壓計一次只允許與一台裝置配對。如果你之前已用官方 Omron Connect App 配對,必須先到手機藍牙設定中解除配對,才能讓本 App 連線。解除後需重新進行「長按藍牙按鈕顯示 -P-」的配對流程。
配對金鑰與型號差異
本文使用 omblepy 的預設金鑰 deadbeaf12341234deadbeaf12341234,這是一個任意選定的 16 bytes,在配對時寫入裝置 EEPROM。較新型號(如 HEM-7140T1)使用 FE4A 服務,byte 格式不同,需另行研究。
偵錯方式
如果下載後沒有解析到記錄,開啟 Chrome DevTools → Console,會看到原始的 Uint8Array 資料。搭配 nRF Connect App 掃描裝置,對照實際的 UUID 和資料值,就能找出正確的 byte offset。
8. 延伸方向
這個 App 目前是個人使用的最小可行版本,有幾個明顯可以擴充的方向:
多用戶支援:Omron 血壓計支援 User 1 / User 2 兩組記錄,資料格式中有 user ID 欄位,可以分開顯示和統計。
雲端同步:目前資料存在 localStorage,只在單一瀏覽器可用。接一個簡單的後端(Cloudflare Workers + D1 或 Supabase)就能跨裝置同步。
PWA 離線支援:加入 manifest.json 和 Service Worker,就能安裝到桌面,離線也能使用。
ESP32 藍牙橋接:如果要支援 Safari / iOS,可以用 ESP32 作為 BLE-to-WebSocket 橋接器(omblepy 有 semi-finished 的 ESP32 bridge 版本可參考),Web App 改成連 WebSocket 接收資料。
Home Assistant 整合:如果你用 Home Assistant,eigger/hass-omron 提供現成的整合,可以自動記錄到 HA 的歷史資料庫,並建立 Lovelace 儀表板。
本文所有程式碼以 MIT 授權釋出,歡迎自由使用與改作。
如果你的 Omron 型號不在支援列表中,或是遇到連線問題,歡迎留言分享你的 nRF Connect 掃描結果(Service UUID、Characteristic UUID),一起完善這份文件。
