{"id":15449,"date":"2026-05-14T07:00:02","date_gmt":"2026-05-13T23:00:02","guid":{"rendered":"https:\/\/fgchen.com\/wpedu\/?p=15449"},"modified":"2026-05-14T07:00:06","modified_gmt":"2026-05-13T23:00:06","slug":"%e7%94%a8-web-bluetooth-api-%e6%89%93%e9%80%a0%e8%a1%80%e5%a3%93%e8%a8%98%e9%8c%84-web-app%ef%bc%9a%e5%be%9e%e9%9b%b6%e5%88%b0-omron-ble-%e5%8d%94%e5%ae%9a%e9%80%86%e5%90%91%e5%b7%a5%e7%a8%8b","status":"publish","type":"post","link":"https:\/\/fgchen.com\/wpedu\/2026\/05\/%e7%94%a8-web-bluetooth-api-%e6%89%93%e9%80%a0%e8%a1%80%e5%a3%93%e8%a8%98%e9%8c%84-web-app%ef%bc%9a%e5%be%9e%e9%9b%b6%e5%88%b0-omron-ble-%e5%8d%94%e5%ae%9a%e9%80%86%e5%90%91%e5%b7%a5%e7%a8%8b\/","title":{"rendered":"\u7528 Web Bluetooth API \u6253\u9020\u8840\u58d3\u8a18\u9304 Web App\uff1a\u5f9e\u96f6\u5230 Omron BLE \u5354\u5b9a\u9006\u5411\u5de5\u7a0b"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\"><\/h2>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>\u8cb7\u4e86\u4e00\u53f0 Omron JPN616T \u8840\u58d3\u8a08\uff0c\u88dd\u7f6e\u4e0a\u6709\u500b\u85cd\u7259\u6309\u9215\uff0c\u5b98\u65b9 App \u53ef\u4ee5\u540c\u6b65\u8cc7\u6599\uff0c\u4f46\u8cc7\u6599\u88ab\u9396\u5728\u5ee0\u5546\u7684\u96f2\u7aef\u2014\u2014\u6c92\u6709\u958b\u653e API\u3001\u6c92\u6709\u532f\u51fa\u529f\u80fd\u3002\u8eab\u70ba\u958b\u767c\u8005\uff0c\u81ea\u7136\u60f3\u81ea\u5df1\u505a\u4e00\u500b\u3002<\/p>\n\n\n\n<p>\u9019\u7bc7\u6587\u7ae0\u8a18\u9304\u6574\u500b\u904e\u7a0b\uff1a\u5f9e\u7814\u7a76 Omron \u7684 BLE \u901a\u8a0a\u5354\u5b9a\uff0c\u5230\u7528\u7d14\u524d\u7aef\uff08\u4e0d\u9700\u5f8c\u7aef\u3001\u4e0d\u9700 App\uff09\u7684\u65b9\u5f0f\uff0c\u5728\u700f\u89bd\u5668\u88e1\u76f4\u63a5\u8b80\u53d6\u8840\u58d3\u8a08\u7684\u91cf\u6e2c\u8a18\u9304\u3002<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\u76ee\u9304<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#1-%E6%8A%80%E8%A1%93%E9%81%B8%E5%9E%8B\">\u6280\u8853\u9078\u578b\uff1a\u70ba\u4ec0\u9ebc\u9078 Web Bluetooth API<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#2-omron-%E7%9A%84-ble-%E5%8D%94%E5%AE%9A\">Omron \u7684 BLE \u5354\u5b9a\uff1a\u4e0d\u662f\u6a19\u6e96 GATT<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#3-%E9%96%8B%E6%BA%90%E5%8F%83%E8%80%83%E5%B0%88%E6%A1%88\">\u958b\u6e90\u53c3\u8003\u5c08\u6848<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#4-%E6%9E%B6%E6%A7%8B%E8%A8%AD%E8%A8%88\">\u67b6\u69cb\u8a2d\u8a08<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#5-%E5%AE%8C%E6%95%B4%E5%8E%9F%E5%A7%8B%E7%A2%BC\">\u5b8c\u6574\u539f\u59cb\u78bc<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#6-%E7%A8%8B%E5%BC%8F%E7%A2%BC%E8%A7%A3%E8%AA%AA\">\u7a0b\u5f0f\u78bc\u89e3\u8aaa<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#7-%E8%B8%A9%E5%9D%91%E7%B4%80%E9%8C%84%E8%88%87%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A0%85\">\u8e29\u5751\u7d00\u9304\u8207\u6ce8\u610f\u4e8b\u9805<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/chat\/ced682bf-6844-4a02-a744-118b556176c6#8-%E5%BB%B6%E4%BC%B8%E6%96%B9%E5%90%91\">\u5ef6\u4f38\u65b9\u5411<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">1. \u6280\u8853\u9078\u578b<\/h2>\n\n\n\n<p>\u8981\u5728 Web \u4e0a\u8b80\u53d6\u85cd\u7259\u88dd\u7f6e\uff0c\u6709\u4e09\u689d\u8def\uff1a<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>\u65b9\u6848<\/th><th>\u512a\u9ede<\/th><th>\u7f3a\u9ede<\/th><\/tr><\/thead><tbody><tr><td><strong>Web Bluetooth API<\/strong><\/td><td>\u7d14\u700f\u89bd\u5668\uff0c\u96f6\u5b89\u88dd\uff0c\u7a0b\u5f0f\u78bc\u7c21\u55ae<\/td><td>\u50c5\u652f\u63f4 Chrome \/ Edge\uff1b\u9700 HTTPS<\/td><\/tr><tr><td><strong>\u539f\u751f App \u6a4b\u63a5<\/strong>\uff08Electron \/ Flutter\uff09<\/td><td>\u8de8\u5e73\u53f0\uff0c\u652f\u63f4 iOS<\/td><td>\u9700\u8981\u5b89\u88dd\uff0c\u958b\u767c\u6210\u672c\u9ad8<\/td><\/tr><tr><td><strong>BLE \u9598\u9053\u5668<\/strong>\uff08Raspberry Pi + Node.js\uff09<\/td><td>\u81ea\u52d5\u540c\u6b65\uff0c\u4e0d\u4f9d\u8cf4\u624b\u6a5f<\/td><td>\u9700\u8981\u984d\u5916\u786c\u9ad4\u548c\u4f3a\u670d\u5668<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>\u672c\u6587\u9078\u64c7 <strong>Web Bluetooth API<\/strong>\uff0c\u539f\u56e0\u5f88\u7c21\u55ae\uff1a\u500b\u4eba\u7528\u9014\u3001\u684c\u6a5f Chrome\u3001\u80fd\u76f4\u63a5\u5206\u4eab HTML \u6a94\u6848\u7d66\u5bb6\u4eba\u4f7f\u7528\u3002\u5982\u679c\u4f60\u9700\u8981\u652f\u63f4 iOS \u6216\u591a\u4eba\u5171\u7528\uff0c\u53ef\u4ee5\u8003\u616e\u5f8c\u5169\u8005\u3002<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">2. Omron \u7684 BLE \u5354\u5b9a<\/h2>\n\n\n\n<p>\u9019\u662f\u6574\u500b\u5c08\u6848\u6700\u6709\u8da3\u7684\u90e8\u5206\u3002<\/p>\n\n\n\n<p>\u591a\u6578 BLE \u91ab\u7642\u88dd\u7f6e\u9075\u5faa\u85cd\u7259 SIG \u5236\u5b9a\u7684 <strong>GATT Blood Pressure Profile<\/strong>\uff08Service UUID <code>0x1810<\/code>\uff09\uff0c\u8cc7\u6599\u683c\u5f0f\u6a19\u6e96\u3001\u958b\u653e\uff0c\u700f\u89bd\u5668\u539f\u751f\u652f\u63f4\u3002\u4f46 Omron \u4e0d\u662f\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u79c1\u6709\u5354\u5b9a UUID<\/h3>\n\n\n\n<p>\u900f\u904e <a href=\"https:\/\/github.com\/userx14\/omblepy\">omblepy<\/a> \u7684\u9006\u5411\u5de5\u7a0b\u6210\u679c\uff0c\u53ef\u4ee5\u6574\u7406\u51fa Omron \u820a\u5354\u5b9a\uff08JPN616T\u3001HEM-7322T\u3001HEM-7600T EVOLV \u7b49\u6a5f\u578b\uff09\u7684\u95dc\u9375 UUID\uff1a<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\u7236\u670d\u52d9\uff08Parent Service\uff09\n  ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b\n\n\u89e3\u9396 Characteristic\uff08Unlock\uff09\n  b305b680-aee7-11e1-a730-0002a5d5c51b\n\nTX Channels\uff08\u4e3b\u6a5f \u2192 \u88dd\u7f6e\uff0c\u4e0b\u6307\u4ee4\uff09\n  db5b55e0-aee7-11e1-965e-0002a5d5c51b\n  e0b8a060-aee7-11e1-92f4-0002a5d5c51b\n  0ae12b00-aee8-11e1-a192-0002a5d5c51b\n  10e1ba60-aee8-11e1-89e5-0002a5d5c51b\n\nRX Channels\uff08\u88dd\u7f6e \u2192 \u4e3b\u6a5f\uff0c\u56de\u50b3\u8cc7\u6599\uff09\n  49123040-aee8-11e1-a74d-0002a5d5c51b\n  4d0bf320-aee8-11e1-a0d9-0002a5d5c51b\n  5128ce60-aee8-11e1-b84b-0002a5d5c51b\n  560f1420-aee8-11e1-8184-0002a5d5c51b\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">\u901a\u8a0a\u6d41\u7a0b<\/h3>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">1. \u9023\u63a5 GATT Server\n2. \u53d6\u5f97\u79c1\u6709\u670d\u52d9\n3. \u5beb\u5165\u914d\u5c0d\u91d1\u9470\uff0816 bytes\uff09\u5230 Unlock Characteristic\n4. \u8a02\u95b1\u56db\u500b RX Channel \u7684 Notification\n5. \u5411 TX Channel \u5beb\u5165\u8b80\u53d6\u6307\u4ee4\n6. \u63a5\u6536\u4e26\u89e3\u6790\u56de\u50b3\u7684\u91cf\u6e2c\u8a18\u9304\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">\u8cc7\u6599\u683c\u5f0f\uff08byte \u6392\u5217\uff09<\/h3>\n\n\n\n<p>Omron \u56de\u50b3\u7684\u6bcf\u7b46\u8a18\u9304\u683c\u5f0f\uff08\u90e8\u5206\u578b\u865f\u7565\u6709\u5dee\u7570\uff09\uff1a<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">Byte  0    : \u6307\u4ee4\u985e\u578b\nByte  1-2  : \u5e74\u4efd\uff08Little-endian uint16\uff09\nByte  3    : \u6708\nByte  4    : \u65e5\nByte  5    : \u6642\nByte  6    : \u5206\nByte  7    : \u79d2\nByte  8-9  : \u6536\u7e2e\u58d3 mmHg\uff08Little-endian uint16\uff09\nByte  10-11: \u8212\u5f35\u58d3 mmHg\uff08Little-endian uint16\uff09\nByte  12-13: \u5fc3\u7387 bpm\uff08Little-endian uint16\uff09\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">\u914d\u5c0d\u91d1\u9470<\/h3>\n\n\n\n<p>Omron \u4f7f\u7528 <strong>\u61c9\u7528\u5c64\u52a0\u5bc6<\/strong>\uff0c\u5728\u6a19\u6e96 BLE \u914d\u5c0d\u4e4b\u5916\uff0c\u9084\u9700\u8981\u5c07\u4e00\u500b 16-byte \u91d1\u9470\u5beb\u5165\u88dd\u7f6e\u7684 Unlock Characteristic\u3002omblepy \u4f7f\u7528\u7684\u9810\u8a2d\u91d1\u9470\u662f\uff1a<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">deadbeaf12341234deadbeaf12341234\n<\/pre>\n\n\n\n<p>\u9019\u500b\u91d1\u9470\u5728\u9996\u6b21\u300c\u914d\u5c0d\u300d\u6642\u5beb\u5165\u88dd\u7f6e\u7684 EEPROM\uff0c\u4e4b\u5f8c\u6bcf\u6b21\u9023\u7dda\u90fd\u9700\u8981\u63d0\u4f9b\u76f8\u540c\u91d1\u9470\u624d\u80fd\u8b80\u53d6\u8cc7\u6599\u3002<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p><strong>\u6ce8\u610f<\/strong>\uff1aOmron HEM-7140T1 \u7b49\u8f03\u65b0\u6a5f\u578b\u4f7f\u7528 <code>FE4A<\/code> \u670d\u52d9\uff0c\u5354\u5b9a\u4e0d\u540c\uff0c\u672c\u6587\u7a0b\u5f0f\u78bc\u672a\u6db5\u84cb\u3002<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">3. \u958b\u6e90\u53c3\u8003\u5c08\u6848<\/h2>\n\n\n\n<p>\u7814\u7a76 Omron BLE \u5354\u5b9a\u6642\uff0c\u9019\u4e9b\u958b\u6e90\u5c08\u6848\u975e\u5e38\u6709\u5e6b\u52a9\uff1a<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><a href=\"https:\/\/github.com\/userx14\/omblepy\">userx14\/omblepy<\/a><\/h3>\n\n\n\n<p>Python CLI \u5de5\u5177\uff0c\u5c08\u9580\u8b80\u53d6 Omron BLE \u8840\u58d3\u8a08\u7684\u91cf\u6e2c\u8a18\u9304\u3002\u6574\u500b\u5c08\u6848\u7684 BLE \u5354\u5b9a\u77e5\u8b58\u5e7e\u4e4e\u90fd\u4f86\u81ea\u9019\u88e1\u3002\u95dc\u9375\u6a94\u6848\u662f <code>omblepy.py<\/code> \u548c <code>deviceSpecific\/<\/code> \u76ee\u9304\u4e0b\u5404\u578b\u865f\u7684\u9a45\u52d5\u3002<\/p>\n\n\n\n<p>\u652f\u63f4\u578b\u865f\uff1aHEM-7322T\u3001HEM-7600T\uff08EVOLV\uff09\u3001HEM-7361T \u7b49\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><a href=\"https:\/\/github.com\/eigger\/hass-omron\">eigger\/hass-omron<\/a><\/h3>\n\n\n\n<p>Home Assistant \u7684 Omron BLE \u6574\u5408\uff0c\u540c\u6a23\u4ee5 Python \u5be6\u4f5c\uff0c\u6703\u81ea\u52d5\u8f2a\u8a62\u4e26\u5efa\u7acb\u6536\u7e2e\u58d3\u3001\u8212\u5f35\u58d3\u3001\u5fc3\u7387\u3001\u8896\u5e36\u8cbc\u5408\u5ea6\u7b49 HA \u611f\u6e2c\u5668\u5be6\u9ad4\u3002\u914d\u5c0d\u6d41\u7a0b\u7684 Python \u5be6\u4f5c\u53ef\u4f5c\u70ba\u5c0d\u7167\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><a href=\"https:\/\/github.com\/evnleong\/open-BPM\">evnleong\/open-BPM<\/a><\/h3>\n\n\n\n<p>Raspberry Pi + LoRaWAN \u7684\u8840\u58d3\u8cc7\u6599\u50b3\u8f38\u65b9\u6848\uff0c\u652f\u63f4 Omron BP7450\uff08M7\uff09\u548c EVOLV\uff0c\u9700\u5148\u7528 omblepy \u5b8c\u6210\u914d\u5c0d\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><a href=\"https:\/\/codeberg.org\/LazyT\/ubpm\">codeberg.org\/LazyT\/ubpm<\/a><\/h3>\n\n\n\n<p>Universal Blood Pressure Manager\uff0c\u684c\u9762\u61c9\u7528\u7a0b\u5f0f\uff0c\u652f\u63f4 Windows \/ Linux \/ macOS\uff0c\u53ef\u4ee5\u5f9e\u591a\u7a2e\u8840\u58d3\u8a08\uff08\u542b Omron BLE \u88dd\u7f6e\uff09\u532f\u5165\u8cc7\u6599\uff0c\u4e26\u63d0\u4f9b\u5716\u8868\u3001\u7d71\u8a08\u3001SQL \u67e5\u8a62\u3001CSV\/JSON \u532f\u51fa\u7b49\u529f\u80fd\u3002<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">4. \u67b6\u69cb\u8a2d\u8a08<\/h2>\n\n\n\n<p>\u9019\u500b Web App \u662f\u4e00\u500b<strong>\u55ae\u4e00 HTML \u6a94\u6848<\/strong>\uff0c\u6c92\u6709\u6846\u67b6\u3001\u6c92\u6709\u5efa\u7f6e\u5de5\u5177\uff0c\u6253\u958b\u5373\u7528\u3002<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">bp-tracker.html\n\u251c\u2500\u2500 CSS\uff08\u8a2d\u8a08\u7cfb\u7d71\uff0c\u6df1\u8272\u4e3b\u984c\uff0cCSS \u8b8a\u6578\uff09\n\u251c\u2500\u2500 HTML\uff08\u56db\u500b\u9801\u9762\uff1a\u7e3d\u89bd\u3001\u8a18\u9304\u5217\u8868\u3001\u624b\u52d5\u8f38\u5165\u3001\u85cd\u7259\u4e0b\u8f09\uff09\n\u2514\u2500\u2500 JavaScript\n    \u251c\u2500\u2500 \u8cc7\u6599\u5c64\uff08localStorage \u6301\u4e45\u5316\uff09\n    \u251c\u2500\u2500 \u5716\u8868\uff08Chart.js\uff0c\u8da8\u52e2\u6298\u7dda\u5716\uff09\n    \u251c\u2500\u2500 \u532f\u51fa\u5165\uff08CSV \/ JSON\uff09\n    \u2514\u2500\u2500 BLE \u5c64\n        \u251c\u2500\u2500 \u6a19\u6e96 GATT Profile\uff080x1810\uff09\n        \u2514\u2500\u2500 Omron \u79c1\u6709\u5354\u5b9a\uff08ecbe3980-\u2026\uff09\n<\/pre>\n\n\n\n<p><strong>\u8840\u58d3\u5206\u985e<\/strong>\u4f9d\u64da ACC\/AHA 2017 \u6a19\u6e96\uff1a<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>\u5206\u985e<\/th><th>\u6536\u7e2e\u58d3<\/th><th>\u8212\u5f35\u58d3<\/th><\/tr><\/thead><tbody><tr><td>\u6b63\u5e38<\/td><td>&lt; 120<\/td><td>&lt; 80<\/td><\/tr><tr><td>\u504f\u9ad8<\/td><td>120\u2013129<\/td><td>&lt; 80<\/td><\/tr><tr><td>\u9ad8\u8840\u58d31\u671f<\/td><td>130\u2013139<\/td><td>80\u201389<\/td><\/tr><tr><td>\u9ad8\u8840\u58d32\u671f<\/td><td>\u2265 140<\/td><td>\u2265 90<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">5. \u5b8c\u6574\u539f\u59cb\u78bc<\/h2>\n\n\n\n<p>\u4ee5\u4e0b\u662f\u5b8c\u6574\u7684 <code>bp-tracker.html<\/code>\uff0c\u5b58\u6210\u6a94\u6848\u5f8c\u7528 Chrome \u958b\u555f\u5373\u53ef\u4f7f\u7528\uff08\u85cd\u7259\u529f\u80fd\u9700 HTTPS \u6216 localhost\uff09\u3002<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">&lt;!DOCTYPE html>\n&lt;html lang=\"zh-TW\">\n&lt;head>\n&lt;meta charset=\"UTF-8\">\n&lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n&lt;title>\u8840\u58d3\u8a18\u9304 \u00b7 BP Tracker&lt;\/title>\n&lt;link rel=\"preconnect\" href=\"https:\/\/fonts.googleapis.com\">\n&lt;link href=\"https:\/\/fonts.googleapis.com\/css2?family=DM+Serif+Display:ital@0;1&amp;family=DM+Mono:wght@300;400;500&amp;family=Noto+Sans+TC:wght@300;400;500&amp;display=swap\" rel=\"stylesheet\">\n&lt;script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/Chart.js\/4.4.1\/chart.umd.min.js\">&lt;\/script>\n&lt;style>\n  :root {\n    --bg: #0f0e0d;\n    --bg2: #161513;\n    --bg3: #1e1c1a;\n    --border: #2e2b28;\n    --text: #e8e4df;\n    --text2: #8a847c;\n    --text3: #5a5550;\n    --accent: #c8a96e;\n    --accent2: #e8c98e;\n    --red: #d4645a;\n    --green: #6ab87a;\n    --blue: #6a9fd4;\n    --warn: #d4a05a;\n    --radius: 12px;\n    --radius-sm: 8px;\n  }\n\n  * { box-sizing: border-box; margin: 0; padding: 0; }\n\n  body {\n    background: var(--bg);\n    color: var(--text);\n    font-family: 'Noto Sans TC', sans-serif;\n    font-weight: 300;\n    min-height: 100vh;\n    line-height: 1.6;\n  }\n\n  \/* Layout *\/\n  .app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }\n\n  \/* Sidebar *\/\n  .sidebar {\n    background: var(--bg2);\n    border-right: 1px solid var(--border);\n    padding: 32px 20px;\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n    position: sticky;\n    top: 0;\n    height: 100vh;\n  }\n\n  .logo {\n    font-family: 'DM Serif Display', serif;\n    font-size: 22px;\n    color: var(--accent);\n    letter-spacing: 0.02em;\n    margin-bottom: 24px;\n    padding-bottom: 24px;\n    border-bottom: 1px solid var(--border);\n    line-height: 1.2;\n  }\n  .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; }\n\n  .nav-btn {\n    background: none;\n    border: none;\n    color: var(--text2);\n    font-family: 'Noto Sans TC', sans-serif;\n    font-size: 13px;\n    font-weight: 400;\n    padding: 10px 14px;\n    border-radius: var(--radius-sm);\n    cursor: pointer;\n    text-align: left;\n    transition: all 0.15s;\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    letter-spacing: 0.02em;\n  }\n  .nav-btn:hover { background: var(--bg3); color: var(--text); }\n  .nav-btn.active { background: var(--bg3); color: var(--accent); border-left: 2px solid var(--accent); padding-left: 12px; }\n  .nav-btn .icon { font-size: 16px; width: 18px; text-align: center; }\n\n  .sidebar-bottom { margin-top: auto; }\n  .ble-status {\n    padding: 10px 14px;\n    background: var(--bg3);\n    border-radius: var(--radius-sm);\n    font-size: 11px;\n    font-family: 'DM Mono', monospace;\n    color: var(--text3);\n    display: flex;\n    align-items: center;\n    gap: 8px;\n  }\n  .ble-dot {\n    width: 7px; height: 7px; border-radius: 50%;\n    background: var(--text3);\n    flex-shrink: 0;\n    transition: background 0.3s;\n  }\n  .ble-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }\n  .ble-dot.connecting { background: var(--warn); animation: pulse 1s infinite; }\n\n  @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }\n\n  \/* Main content *\/\n  .main { overflow-y: auto; }\n\n  .page { display: none; padding: 40px 48px; }\n  .page.active { display: block; }\n\n  .page-header {\n    margin-bottom: 36px;\n    padding-bottom: 24px;\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: flex-end;\n    justify-content: space-between;\n  }\n  .page-title {\n    font-family: 'DM Serif Display', serif;\n    font-size: 32px;\n    color: var(--text);\n    line-height: 1;\n  }\n  .page-subtitle { font-size: 12px; color: var(--text3); margin-top: 6px; font-family: 'DM Mono', monospace; letter-spacing: 0.08em; }\n\n  \/* Cards *\/\n  .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; }\n\n  .card {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    padding: 20px 24px;\n  }\n  .card-label { font-size: 11px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; margin-bottom: 8px; }\n  .card-value { font-family: 'DM Mono', monospace; font-size: 36px; font-weight: 500; color: var(--text); line-height: 1; }\n  .card-value .unit { font-size: 14px; color: var(--text2); margin-left: 4px; }\n  .card-sub { font-size: 11px; color: var(--text3); margin-top: 8px; }\n  .card-badge {\n    display: inline-block; padding: 2px 8px;\n    border-radius: 4px; font-size: 10px; font-weight: 500;\n    margin-top: 8px; font-family: 'DM Mono', monospace; letter-spacing: 0.05em;\n  }\n  .badge-normal { background: rgba(106,184,122,0.15); color: var(--green); }\n  .badge-elevated { background: rgba(212,160,90,0.15); color: var(--warn); }\n  .badge-high { background: rgba(212,100,90,0.15); color: var(--red); }\n  .badge-low { background: rgba(106,159,212,0.15); color: var(--blue); }\n\n  \/* Chart *\/\n  .chart-card {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    padding: 24px;\n    margin-bottom: 24px;\n  }\n  .chart-title { font-size: 12px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; margin-bottom: 20px; }\n  .chart-wrap { position: relative; height: 220px; }\n\n  \/* Table *\/\n  .table-card {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    overflow: hidden;\n  }\n  .table-header {\n    padding: 16px 24px;\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n  }\n  .table-header-title { font-size: 11px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; }\n\n  table { width: 100%; border-collapse: collapse; }\n  thead th {\n    padding: 12px 24px;\n    text-align: left;\n    font-size: 10px;\n    letter-spacing: 0.12em;\n    color: var(--text3);\n    font-family: 'DM Mono', monospace;\n    border-bottom: 1px solid var(--border);\n    font-weight: 400;\n  }\n  tbody tr { border-bottom: 1px solid var(--border); transition: background 0.1s; }\n  tbody tr:last-child { border-bottom: none; }\n  tbody tr:hover { background: var(--bg3); }\n  tbody td { padding: 14px 24px; font-size: 13px; font-family: 'DM Mono', monospace; color: var(--text2); }\n  tbody td.val { color: var(--text); font-weight: 500; }\n  .row-high td.val { color: var(--red); }\n\n  \/* Buttons *\/\n  .btn {\n    padding: 10px 20px;\n    border-radius: var(--radius-sm);\n    border: none;\n    cursor: pointer;\n    font-family: 'DM Mono', monospace;\n    font-size: 12px;\n    letter-spacing: 0.05em;\n    transition: all 0.15s;\n    display: inline-flex;\n    align-items: center;\n    gap: 8px;\n  }\n  .btn-primary { background: var(--accent); color: #1a1510; font-weight: 500; }\n  .btn-primary:hover { background: var(--accent2); }\n  .btn-secondary { background: var(--bg3); color: var(--text2); border: 1px solid var(--border); }\n  .btn-secondary:hover { color: var(--text); border-color: var(--text3); }\n  .btn-danger { background: rgba(212,100,90,0.15); color: var(--red); border: 1px solid rgba(212,100,90,0.3); }\n  .btn-danger:hover { background: rgba(212,100,90,0.25); }\n  .btn:disabled { opacity: 0.4; cursor: not-allowed; }\n\n  \/* BLE Page *\/\n  .ble-device-panel {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    padding: 32px;\n    margin-bottom: 24px;\n  }\n  .ble-panel-title { font-family: 'DM Serif Display', serif; font-size: 20px; margin-bottom: 8px; }\n  .ble-panel-desc { font-size: 13px; color: var(--text2); margin-bottom: 28px; line-height: 1.7; }\n\n  .ble-steps { display: flex; flex-direction: column; gap: 16px; margin-bottom: 28px; }\n  .ble-step {\n    display: flex;\n    align-items: flex-start;\n    gap: 16px;\n    padding: 16px;\n    background: var(--bg3);\n    border-radius: var(--radius-sm);\n    border: 1px solid var(--border);\n  }\n  .ble-step-num {\n    width: 28px; height: 28px; border-radius: 50%;\n    background: var(--accent);\n    color: #1a1510;\n    font-family: 'DM Mono', monospace;\n    font-size: 13px;\n    font-weight: 500;\n    display: flex; align-items: center; justify-content: center;\n    flex-shrink: 0;\n  }\n  .ble-step-text { font-size: 13px; color: var(--text2); line-height: 1.6; }\n  .ble-step-text strong { color: var(--text); font-weight: 500; }\n\n  .ble-log {\n    background: var(--bg);\n    border: 1px solid var(--border);\n    border-radius: var(--radius-sm);\n    padding: 16px;\n    font-family: 'DM Mono', monospace;\n    font-size: 12px;\n    color: var(--text3);\n    min-height: 100px;\n    max-height: 200px;\n    overflow-y: auto;\n    line-height: 1.8;\n    margin-top: 20px;\n  }\n  .ble-log .log-ok { color: var(--green); }\n  .ble-log .log-warn { color: var(--warn); }\n  .ble-log .log-err { color: var(--red); }\n  .ble-log .log-info { color: var(--blue); }\n\n  .btn-row { display: flex; gap: 12px; flex-wrap: wrap; }\n\n  \/* Add record form *\/\n  .form-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 24px; }\n  .form-group { display: flex; flex-direction: column; gap: 6px; }\n  .form-label { font-size: 10px; color: var(--text3); letter-spacing: 0.1em; font-family: 'DM Mono', monospace; }\n  .form-input {\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: var(--radius-sm);\n    padding: 10px 14px;\n    color: var(--text);\n    font-family: 'DM Mono', monospace;\n    font-size: 14px;\n    outline: none;\n    transition: border-color 0.15s;\n    width: 100%;\n  }\n  .form-input:focus { border-color: var(--accent); }\n\n  \/* Compat warning *\/\n  .compat-warn {\n    background: rgba(212,160,90,0.1);\n    border: 1px solid rgba(212,160,90,0.3);\n    border-radius: var(--radius-sm);\n    padding: 12px 16px;\n    font-size: 12px;\n    color: var(--warn);\n    font-family: 'DM Mono', monospace;\n    display: none;\n    margin-bottom: 20px;\n    line-height: 1.7;\n  }\n\n  \/* Import panel *\/\n  .import-info {\n    background: rgba(106,159,212,0.08);\n    border: 1px solid rgba(106,159,212,0.25);\n    border-radius: var(--radius-sm);\n    padding: 16px;\n    font-size: 12px;\n    color: var(--blue);\n    font-family: 'DM Mono', monospace;\n    line-height: 1.8;\n    margin-bottom: 20px;\n  }\n\n  .divider { border: none; border-top: 1px solid var(--border); margin: 28px 0; }\n\n  \/* Scrollbar *\/\n  ::-webkit-scrollbar { width: 6px; }\n  ::-webkit-scrollbar-track { background: transparent; }\n  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }\n\n  \/* Empty state *\/\n  .empty-state { text-align: center; padding: 60px 24px; color: var(--text3); }\n  .empty-state .empty-icon { font-size: 40px; margin-bottom: 16px; opacity: 0.4; }\n  .empty-state p { font-size: 13px; line-height: 1.7; }\n\n  \/* Toast *\/\n  .toast {\n    position: fixed; bottom: 24px; right: 24px;\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: var(--radius-sm);\n    padding: 12px 20px;\n    font-size: 12px;\n    font-family: 'DM Mono', monospace;\n    color: var(--text);\n    z-index: 1000;\n    opacity: 0;\n    transform: translateY(8px);\n    transition: all 0.3s;\n    pointer-events: none;\n  }\n  .toast.show { opacity: 1; transform: translateY(0); }\n  .toast.success { border-color: rgba(106,184,122,0.4); color: var(--green); }\n  .toast.error { border-color: rgba(212,100,90,0.4); color: var(--red); }\n&lt;\/style>\n&lt;\/head>\n&lt;body>\n&lt;div class=\"app\">\n  &lt;!-- Sidebar -->\n  &lt;aside class=\"sidebar\">\n    &lt;div class=\"logo\">\n      BP Tracker\n      &lt;span>\u8840\u58d3\u8a18\u9304\u7cfb\u7d71 v1.0&lt;\/span>\n    &lt;\/div>\n\n    &lt;button class=\"nav-btn active\" onclick=\"showPage('dashboard')\" id=\"nav-dashboard\">\n      &lt;span class=\"icon\">\u25c8&lt;\/span> \u7e3d\u89bd\n    &lt;\/button>\n    &lt;button class=\"nav-btn\" onclick=\"showPage('records')\" id=\"nav-records\">\n      &lt;span class=\"icon\">\u2261&lt;\/span> \u8a18\u9304\u5217\u8868\n    &lt;\/button>\n    &lt;button class=\"nav-btn\" onclick=\"showPage('add')\" id=\"nav-add\">\n      &lt;span class=\"icon\">\uff0b&lt;\/span> \u624b\u52d5\u8f38\u5165\n    &lt;\/button>\n    &lt;button class=\"nav-btn\" onclick=\"showPage('bluetooth')\" id=\"nav-bluetooth\">\n      &lt;span class=\"icon\">\u2b21&lt;\/span> \u85cd\u7259\u4e0b\u8f09\n    &lt;\/button>\n\n    &lt;div class=\"sidebar-bottom\">\n      &lt;div class=\"ble-status\">\n        &lt;div class=\"ble-dot\" id=\"ble-dot\">&lt;\/div>\n        &lt;span id=\"ble-status-text\">\u672a\u9023\u63a5&lt;\/span>\n      &lt;\/div>\n    &lt;\/div>\n  &lt;\/aside>\n\n  &lt;!-- Main -->\n  &lt;main class=\"main\">\n\n    &lt;!-- Dashboard -->\n    &lt;div class=\"page active\" id=\"page-dashboard\">\n      &lt;div class=\"page-header\">\n        &lt;div>\n          &lt;div class=\"page-title\">\u7e3d\u89bd&lt;\/div>\n          &lt;div class=\"page-subtitle\">DASHBOARD \u00b7 &lt;span id=\"dash-count\">0&lt;\/span> \u7b46\u8a18\u9304&lt;\/div>\n        &lt;\/div>\n        &lt;button class=\"btn btn-secondary\" onclick=\"exportCSV()\">\u2b07 \u532f\u51fa CSV&lt;\/button>\n      &lt;\/div>\n\n      &lt;div class=\"cards\">\n        &lt;div class=\"card\">\n          &lt;div class=\"card-label\">\u6536\u7e2e\u58d3 \u5e73\u5747&lt;\/div>\n          &lt;div class=\"card-value\" id=\"avg-sys\">\u2014&lt;span class=\"unit\">mmHg&lt;\/span>&lt;\/div>\n          &lt;div class=\"card-sub\" id=\"avg-sys-sub\">\u6700\u8fd1 7 \u5929&lt;\/div>\n        &lt;\/div>\n        &lt;div class=\"card\">\n          &lt;div class=\"card-label\">\u8212\u5f35\u58d3 \u5e73\u5747&lt;\/div>\n          &lt;div class=\"card-value\" id=\"avg-dia\">\u2014&lt;span class=\"unit\">mmHg&lt;\/span>&lt;\/div>\n          &lt;div class=\"card-sub\" id=\"avg-dia-sub\">\u6700\u8fd1 7 \u5929&lt;\/div>\n        &lt;\/div>\n        &lt;div class=\"card\">\n          &lt;div class=\"card-label\">\u5fc3\u7387 \u5e73\u5747&lt;\/div>\n          &lt;div class=\"card-value\" id=\"avg-pulse\">\u2014&lt;span class=\"unit\">bpm&lt;\/span>&lt;\/div>\n          &lt;div class=\"card-sub\" id=\"avg-pulse-sub\">\u6700\u8fd1 7 \u5929&lt;\/div>\n        &lt;\/div>\n      &lt;\/div>\n\n      &lt;div class=\"cards\" style=\"grid-template-columns: repeat(2, 1fr);\">\n        &lt;div class=\"card\">\n          &lt;div class=\"card-label\">\u6700\u65b0\u91cf\u6e2c&lt;\/div>\n          &lt;div class=\"card-value\" id=\"latest-val\">\u2014&lt;\/div>\n          &lt;div class=\"card-sub\" id=\"latest-time\">\u5c1a\u7121\u8a18\u9304&lt;\/div>\n          &lt;div id=\"latest-badge\">&lt;\/div>\n        &lt;\/div>\n        &lt;div class=\"card\">\n          &lt;div class=\"card-label\">\u8840\u58d3\u5206\u985e (ACC\/AHA 2017)&lt;\/div>\n          &lt;div style=\"margin-top: 8px; display: flex; flex-direction: column; gap: 6px; font-size: 11px; font-family: 'DM Mono', monospace; color: var(--text3);\">\n            &lt;div>&lt;span class=\"card-badge badge-normal\">\u6b63\u5e38&lt;\/span> &amp;lt;120\/&amp;lt;80&lt;\/div>\n            &lt;div>&lt;span class=\"card-badge badge-elevated\">\u504f\u9ad8&lt;\/span> 120-129\/&amp;lt;80&lt;\/div>\n            &lt;div>&lt;span class=\"card-badge badge-high\">\u9ad8\u8840\u58d31\u671f&lt;\/span> 130-139\/80-89&lt;\/div>\n            &lt;div>&lt;span class=\"card-badge badge-high\" style=\"background:rgba(212,100,90,0.25)\">\u9ad8\u8840\u58d32\u671f&lt;\/span> \u2265140\/\u226590&lt;\/div>\n          &lt;\/div>\n        &lt;\/div>\n      &lt;\/div>\n\n      &lt;div class=\"chart-card\">\n        &lt;div class=\"chart-title\">TREND \u00b7 \u8da8\u52e2\u5716\uff08\u8fd1 30 \u7b46\uff09&lt;\/div>\n        &lt;div class=\"chart-wrap\">\n          &lt;canvas id=\"bp-chart\">&lt;\/canvas>\n        &lt;\/div>\n      &lt;\/div>\n    &lt;\/div>\n\n    &lt;!-- Records -->\n    &lt;div class=\"page\" id=\"page-records\">\n      &lt;div class=\"page-header\">\n        &lt;div>\n          &lt;div class=\"page-title\">\u8a18\u9304\u5217\u8868&lt;\/div>\n          &lt;div class=\"page-subtitle\">RECORDS \u00b7 \u5168\u90e8\u91cf\u6e2c\u8cc7\u6599&lt;\/div>\n        &lt;\/div>\n        &lt;div style=\"display:flex; gap: 10px;\">\n          &lt;button class=\"btn btn-danger\" onclick=\"clearAll()\">\u2715 \u6e05\u9664\u5168\u90e8&lt;\/button>\n          &lt;button class=\"btn btn-secondary\" onclick=\"exportCSV()\">\u2b07 \u532f\u51fa CSV&lt;\/button>\n        &lt;\/div>\n      &lt;\/div>\n      &lt;div class=\"table-card\">\n        &lt;div id=\"records-body\">&lt;\/div>\n      &lt;\/div>\n    &lt;\/div>\n\n    &lt;!-- Add -->\n    &lt;div class=\"page\" id=\"page-add\">\n      &lt;div class=\"page-header\">\n        &lt;div>\n          &lt;div class=\"page-title\">\u624b\u52d5\u8f38\u5165&lt;\/div>\n          &lt;div class=\"page-subtitle\">MANUAL ENTRY \u00b7 \u65b0\u589e\u91cf\u6e2c\u8a18\u9304&lt;\/div>\n        &lt;\/div>\n      &lt;\/div>\n      &lt;div class=\"ble-device-panel\">\n        &lt;div class=\"form-grid\">\n          &lt;div class=\"form-group\">\n            &lt;label class=\"form-label\">\u6536\u7e2e\u58d3 (mmHg)&lt;\/label>\n            &lt;input type=\"number\" class=\"form-input\" id=\"inp-sys\" placeholder=\"120\" min=\"60\" max=\"250\">\n          &lt;\/div>\n          &lt;div class=\"form-group\">\n            &lt;label class=\"form-label\">\u8212\u5f35\u58d3 (mmHg)&lt;\/label>\n            &lt;input type=\"number\" class=\"form-input\" id=\"inp-dia\" placeholder=\"80\" min=\"40\" max=\"150\">\n          &lt;\/div>\n          &lt;div class=\"form-group\">\n            &lt;label class=\"form-label\">\u5fc3\u7387 (bpm)&lt;\/label>\n            &lt;input type=\"number\" class=\"form-input\" id=\"inp-pulse\" placeholder=\"72\" min=\"30\" max=\"200\">\n          &lt;\/div>\n          &lt;div class=\"form-group\">\n            &lt;label class=\"form-label\">\u91cf\u6e2c\u6642\u9593&lt;\/label>\n            &lt;input type=\"datetime-local\" class=\"form-input\" id=\"inp-time\">\n          &lt;\/div>\n        &lt;\/div>\n        &lt;div class=\"form-group\" style=\"margin-bottom: 20px;\">\n          &lt;label class=\"form-label\">\u5099\u6ce8&lt;\/label>\n          &lt;input type=\"text\" class=\"form-input\" id=\"inp-note\" placeholder=\"\u65e9\u6668\u91cf\u6e2c\u3001\u98ef\u5f8c 30 \u5206\u9418\u2026\">\n        &lt;\/div>\n        &lt;button class=\"btn btn-primary\" onclick=\"addManual()\">\uff0b \u65b0\u589e\u8a18\u9304&lt;\/button>\n      &lt;\/div>\n    &lt;\/div>\n\n    &lt;!-- Bluetooth -->\n    &lt;div class=\"page\" id=\"page-bluetooth\">\n      &lt;div class=\"page-header\">\n        &lt;div>\n          &lt;div class=\"page-title\">\u85cd\u7259\u4e0b\u8f09&lt;\/div>\n          &lt;div class=\"page-subtitle\">BLE DOWNLOAD \u00b7 Omron \u8840\u58d3\u8a08&lt;\/div>\n        &lt;\/div>\n      &lt;\/div>\n\n      &lt;div class=\"compat-warn\" id=\"compat-warn\">\n        \u26a0 \u60a8\u7684\u700f\u89bd\u5668\u4e0d\u652f\u63f4 Web Bluetooth API\u3002\u8acb\u4f7f\u7528 Chrome \u6216 Edge\uff08\u684c\u9762\u7248\uff09\u3002&lt;br>\n        iOS \/ Safari \u7528\u6236\u8acb\u53c3\u8003\u4e0b\u65b9\u66ff\u4ee3\u65b9\u6848\u3002\n      &lt;\/div>\n\n      &lt;div class=\"ble-device-panel\">\n        &lt;div class=\"ble-panel-title\">Omron \u8840\u58d3\u8a08\u9023\u7dda&lt;\/div>\n        &lt;div class=\"ble-panel-desc\">\n          \u652f\u63f4\u4f7f\u7528 Omron \u79c1\u6709 BLE \u5354\u5b9a\u7684\u6a5f\u578b\uff0c\u5305\u542b JPN616T\u3001HEM-7322T\u3001HEM-7600T\uff08EVOLV\uff09\u7b49\u3002&lt;br>\n          \u9996\u6b21\u914d\u5c0d\u9700\u5728\u8840\u58d3\u8a08\u4e0a\u555f\u52d5\u914d\u5c0d\u6a21\u5f0f\uff0c\u4e4b\u5f8c\u6bcf\u6b21\u9023\u7dda\u53ea\u9700\u6309\u4e0b\u85cd\u7259\u6309\u9215\u5373\u53ef\u3002\n        &lt;\/div>\n\n        &lt;div class=\"ble-steps\">\n          &lt;div class=\"ble-step\">\n            &lt;div class=\"ble-step-num\">1&lt;\/div>\n            &lt;div class=\"ble-step-text\">\n              \u9577\u6309\u8840\u58d3\u8a08\u4e0a\u7684\u85cd\u7259\u6309\u9215\uff0c\u76f4\u5230\u87a2\u5e55\u986f\u793a\u9583\u720d\u7684 &lt;strong>-P-&lt;\/strong>\uff08\u914d\u5c0d\u6a21\u5f0f\uff09\u3002&lt;br>\n              &lt;small>\u82e5\u5df2\u914d\u5c0d\u904e\uff0c\u77ed\u6309\u85cd\u7259\u6309\u9215\u9032\u5165\u9023\u7dda\u6a21\u5f0f\u5373\u53ef\u3002&lt;\/small>\n            &lt;\/div>\n          &lt;\/div>\n          &lt;div class=\"ble-step\">\n            &lt;div class=\"ble-step-num\">2&lt;\/div>\n            &lt;div class=\"ble-step-text\">\u9ede\u64ca\u4e0b\u65b9\u300c\u6383\u63cf\u88dd\u7f6e\u300d\uff0c\u5728\u700f\u89bd\u5668\u5f48\u7a97\u4e2d\u9078\u64c7\u60a8\u7684\u8840\u58d3\u8a08\uff08\u540d\u7a31\u901a\u5e38\u70ba &lt;strong>BLEsmart&lt;\/strong> \u6216 &lt;strong>BP&lt;\/strong> \u958b\u982d\uff09\u3002&lt;\/div>\n          &lt;\/div>\n          &lt;div class=\"ble-step\">\n            &lt;div class=\"ble-step-num\">3&lt;\/div>\n            &lt;div class=\"ble-step-text\">\u9023\u7dda\u6210\u529f\u5f8c\uff0c\u9ede\u64ca\u300c\u4e0b\u8f09\u8cc7\u6599\u300d\uff0c\u7cfb\u7d71\u5c07\u81ea\u52d5\u8b80\u53d6\u88dd\u7f6e\u4e2d\u5132\u5b58\u7684\u6240\u6709\u91cf\u6e2c\u8a18\u9304\u3002&lt;\/div>\n          &lt;\/div>\n        &lt;\/div>\n\n        &lt;div class=\"btn-row\">\n          &lt;button class=\"btn btn-primary\" id=\"btn-scan\" onclick=\"bleScan()\">\u2b21 \u6383\u63cf\u88dd\u7f6e&lt;\/button>\n          &lt;button class=\"btn btn-secondary\" id=\"btn-download\" onclick=\"bleDownload()\" disabled>\u2b07 \u4e0b\u8f09\u8cc7\u6599&lt;\/button>\n          &lt;button class=\"btn btn-secondary\" id=\"btn-disconnect\" onclick=\"bleDisconnect()\" disabled>\u2715 \u4e2d\u65b7\u9023\u7dda&lt;\/button>\n        &lt;\/div>\n\n        &lt;div class=\"ble-log\" id=\"ble-log\">\u7b49\u5f85\u64cd\u4f5c\u2026&lt;\/div>\n      &lt;\/div>\n\n      &lt;hr class=\"divider\">\n\n      &lt;div class=\"ble-device-panel\">\n        &lt;div class=\"ble-panel-title\" style=\"font-size: 16px;\">\u532f\u5165 JSON \/ CSV \u6a94\u6848&lt;\/div>\n        &lt;div class=\"import-info\">\n          \u2139 \u82e5\u4f7f\u7528 iOS \/ Safari\uff0c\u53ef\u5148\u7528 omblepy\uff08Python\uff09\u5de5\u5177\u5c07\u8cc7\u6599\u532f\u51fa\u70ba CSV \u6216 JSON\uff0c\u518d\u7528\u6b64\u529f\u80fd\u532f\u5165\u3002&lt;br>\n          CSV \u683c\u5f0f\uff1adatetime, systolic, diastolic, pulse, note\uff08\u7b2c\u4e00\u884c\u70ba\u8868\u982d\uff09\n        &lt;\/div>\n        &lt;div class=\"btn-row\">\n          &lt;button class=\"btn btn-secondary\" onclick=\"document.getElementById('file-input').click()\">\ud83d\udcc2 \u9078\u64c7\u6a94\u6848&lt;\/button>\n          &lt;input type=\"file\" id=\"file-input\" accept=\".csv,.json\" style=\"display:none\" onchange=\"importFile(this)\">\n        &lt;\/div>\n      &lt;\/div>\n    &lt;\/div>\n\n  &lt;\/main>\n&lt;\/div>\n\n&lt;div class=\"toast\" id=\"toast\">&lt;\/div>\n\n&lt;script>\n\/\/ \u2500\u2500 Data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet records = JSON.parse(localStorage.getItem('bp_records') || '[]');\nlet chart = null;\nlet bleDevice = null;\nlet bleServer = null;\n\nfunction save() {\n  localStorage.setItem('bp_records', JSON.stringify(records));\n}\n\n\/\/ \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction showPage(name) {\n  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));\n  document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));\n  document.getElementById('page-' + name).classList.add('active');\n  document.getElementById('nav-' + name).classList.add('active');\n  if (name === 'dashboard') renderDashboard();\n  if (name === 'records') renderRecords();\n  if (name === 'add') {\n    const now = new Date();\n    now.setMinutes(now.getMinutes() - now.getTimezoneOffset());\n    document.getElementById('inp-time').value = now.toISOString().slice(0, 16);\n  }\n}\n\n\/\/ \u2500\u2500 Classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction classify(sys, dia) {\n  if (sys &lt; 120 &amp;&amp; dia &lt; 80) return { label: '\u6b63\u5e38', cls: 'badge-normal' };\n  if (sys &lt; 130 &amp;&amp; dia &lt; 80) return { label: '\u504f\u9ad8', cls: 'badge-elevated' };\n  if (sys &lt; 140 || dia &lt; 90) return { label: '\u9ad8\u8840\u58d31\u671f', cls: 'badge-high' };\n  return { label: '\u9ad8\u8840\u58d32\u671f', cls: 'badge-high' };\n}\n\n\/\/ \u2500\u2500 Dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction renderDashboard() {\n  const sorted = [...records].sort((a, b) => new Date(b.time) - new Date(a.time));\n  document.getElementById('dash-count').textContent = records.length;\n\n  if (sorted.length > 0) {\n    const r = sorted[0];\n    document.getElementById('latest-val').innerHTML =\n      `${r.sys}\/${r.dia} &lt;span class=\"unit\">mmHg&lt;\/span>`;\n    document.getElementById('latest-time').textContent =\n      new Date(r.time).toLocaleString('zh-TW');\n    const cat = classify(r.sys, r.dia);\n    document.getElementById('latest-badge').innerHTML =\n      `&lt;div class=\"card-badge ${cat.cls}\">${cat.label}&lt;\/div>`;\n  }\n\n  const week = sorted.slice(0, 7);\n  if (week.length > 0) {\n    const avgSys = Math.round(week.reduce((s, r) => s + r.sys, 0) \/ week.length);\n    const avgDia = Math.round(week.reduce((s, r) => s + r.dia, 0) \/ week.length);\n    const avgPulse = Math.round(week.reduce((s, r) => s + r.pulse, 0) \/ week.length);\n    document.getElementById('avg-sys').innerHTML = `${avgSys}&lt;span class=\"unit\">mmHg&lt;\/span>`;\n    document.getElementById('avg-dia').innerHTML = `${avgDia}&lt;span class=\"unit\">mmHg&lt;\/span>`;\n    document.getElementById('avg-pulse').innerHTML = `${avgPulse}&lt;span class=\"unit\">bpm&lt;\/span>`;\n    document.getElementById('avg-sys-sub').textContent = `\u6700\u8fd1 ${week.length} \u7b46`;\n    document.getElementById('avg-dia-sub').textContent = `\u6700\u8fd1 ${week.length} \u7b46`;\n    document.getElementById('avg-pulse-sub').textContent = `\u6700\u8fd1 ${week.length} \u7b46`;\n  }\n\n  const chartData = sorted.slice(0, 30).reverse();\n  const labels = chartData.map(r =>\n    new Date(r.time).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' }));\n  const ctx = document.getElementById('bp-chart').getContext('2d');\n  if (chart) chart.destroy();\n  chart = new Chart(ctx, {\n    type: 'line',\n    data: {\n      labels,\n      datasets: [\n        { label: '\u6536\u7e2e\u58d3', data: chartData.map(r => r.sys),\n          borderColor: '#d4645a', backgroundColor: 'rgba(212,100,90,0.08)',\n          borderWidth: 2, pointRadius: 3, tension: 0.3 },\n        { label: '\u8212\u5f35\u58d3', data: chartData.map(r => r.dia),\n          borderColor: '#6a9fd4', backgroundColor: 'rgba(106,159,212,0.08)',\n          borderWidth: 2, pointRadius: 3, tension: 0.3 },\n        { label: '\u5fc3\u7387', data: chartData.map(r => r.pulse),\n          borderColor: '#6ab87a', backgroundColor: 'rgba(106,184,122,0.08)',\n          borderWidth: 1.5, pointRadius: 2, tension: 0.3, borderDash: [4,3] }\n      ]\n    },\n    options: {\n      responsive: true, maintainAspectRatio: false,\n      plugins: {\n        legend: { labels: { color: '#8a847c', font: { family: 'DM Mono', size: 11 }, boxWidth: 16 } },\n        tooltip: { backgroundColor: '#1e1c1a', titleColor: '#e8e4df', bodyColor: '#8a847c',\n                   borderColor: '#2e2b28', borderWidth: 1 }\n      },\n      scales: {\n        x: { grid: { color: '#2e2b28' }, ticks: { color: '#5a5550', font: { family: 'DM Mono', size: 10 } } },\n        y: { grid: { color: '#2e2b28' }, ticks: { color: '#5a5550', font: { family: 'DM Mono', size: 10 } },\n             suggestedMin: 50, suggestedMax: 180 }\n      },\n      animation: { duration: 600 }\n    }\n  });\n}\n\n\/\/ \u2500\u2500 Records \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction renderRecords() {\n  const sorted = [...records].sort((a, b) => new Date(b.time) - new Date(a.time));\n  const el = document.getElementById('records-body');\n  if (sorted.length === 0) {\n    el.innerHTML = '&lt;div class=\"empty-state\">&lt;div class=\"empty-icon\">\u25c8&lt;\/div>' +\n      '&lt;p>\u5c1a\u7121\u8a18\u9304&lt;br>\u8acb\u624b\u52d5\u8f38\u5165\u6216\u9023\u63a5\u85cd\u7259\u8840\u58d3\u8a08\u4e0b\u8f09\u8cc7\u6599&lt;\/p>&lt;\/div>';\n    return;\n  }\n  let html = '&lt;table>&lt;thead>&lt;tr>&lt;th>\u6642\u9593&lt;\/th>&lt;th>\u6536\u7e2e\u58d3&lt;\/th>&lt;th>\u8212\u5f35\u58d3&lt;\/th>' +\n    '&lt;th>\u5fc3\u7387&lt;\/th>&lt;th>\u5206\u985e&lt;\/th>&lt;th>\u5099\u6ce8&lt;\/th>&lt;th>&lt;\/th>&lt;\/tr>&lt;\/thead>&lt;tbody>';\n  sorted.forEach(r => {\n    const cat = classify(r.sys, r.dia);\n    const isHigh = r.sys >= 140 || r.dia >= 90;\n    html += `&lt;tr class=\"${isHigh ? 'row-high' : ''}\">\n      &lt;td>${new Date(r.time).toLocaleString('zh-TW')}&lt;\/td>\n      &lt;td class=\"val\">${r.sys}&lt;\/td>\n      &lt;td class=\"val\">${r.dia}&lt;\/td>\n      &lt;td class=\"val\">${r.pulse || '\u2014'}&lt;\/td>\n      &lt;td>&lt;span class=\"card-badge ${cat.cls}\">${cat.label}&lt;\/span>&lt;\/td>\n      &lt;td>${r.note || ''}&lt;\/td>\n      &lt;td>&lt;button class=\"btn btn-danger\" style=\"padding:4px 10px;font-size:11px\"\n          onclick=\"deleteRecord(${r.id})\">\u2715&lt;\/button>&lt;\/td>\n    &lt;\/tr>`;\n  });\n  html += '&lt;\/tbody>&lt;\/table>';\n  el.innerHTML = html;\n}\n\nfunction deleteRecord(id) {\n  records = records.filter(r => r.id !== id);\n  save(); renderRecords(); toast('\u8a18\u9304\u5df2\u522a\u9664');\n}\n\nfunction clearAll() {\n  if (!confirm('\u78ba\u5b9a\u8981\u6e05\u9664\u5168\u90e8\u8a18\u9304\u55ce\uff1f\u6b64\u64cd\u4f5c\u7121\u6cd5\u5fa9\u539f\u3002')) return;\n  records = []; save(); renderRecords(); toast('\u5df2\u6e05\u9664\u5168\u90e8\u8a18\u9304');\n}\n\n\/\/ \u2500\u2500 Add Manual \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction addManual() {\n  const sys = parseInt(document.getElementById('inp-sys').value);\n  const dia = parseInt(document.getElementById('inp-dia').value);\n  const pulse = parseInt(document.getElementById('inp-pulse').value) || 0;\n  const time = document.getElementById('inp-time').value;\n  const note = document.getElementById('inp-note').value.trim();\n  if (!sys || !dia || !time) { toast('\u8acb\u586b\u5beb\u6536\u7e2e\u58d3\u3001\u8212\u5f35\u58d3\u8207\u6642\u9593', 'error'); return; }\n  if (sys &lt; 60 || sys > 250 || dia &lt; 40 || dia > 150) { toast('\u6578\u503c\u8d85\u51fa\u5408\u7406\u7bc4\u570d', 'error'); return; }\n  addRecord({ sys, dia, pulse, time, note });\n  document.getElementById('inp-sys').value = '';\n  document.getElementById('inp-dia').value = '';\n  document.getElementById('inp-pulse').value = '';\n  document.getElementById('inp-note').value = '';\n  toast('\u8a18\u9304\u5df2\u65b0\u589e', 'success');\n}\n\nfunction addRecord(r) {\n  r.id = Date.now() + Math.random();\n  records.push(r);\n  save();\n}\n\n\/\/ \u2500\u2500 Export \/ Import \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction exportCSV() {\n  if (records.length === 0) { toast('\u6c92\u6709\u8cc7\u6599\u53ef\u532f\u51fa', 'error'); return; }\n  const sorted = [...records].sort((a, b) => new Date(a.time) - new Date(b.time));\n  let csv = 'datetime,systolic,diastolic,pulse,note\\n';\n  sorted.forEach(r => {\n    csv += `\"${r.time}\",${r.sys},${r.dia},${r.pulse || ''},\"${r.note || ''}\"\\n`;\n  });\n  const blob = new Blob(['\\uFEFF' + csv], { type: 'text\/csv;charset=utf-8;' });\n  const a = document.createElement('a');\n  a.href = URL.createObjectURL(blob);\n  a.download = `bp_records_${new Date().toISOString().slice(0, 10)}.csv`;\n  a.click();\n  toast('CSV \u5df2\u532f\u51fa', 'success');\n}\n\nfunction importFile(input) {\n  const file = input.files[0];\n  if (!file) return;\n  const reader = new FileReader();\n  reader.onload = (e) => {\n    if (file.name.endsWith('.json')) importJSON(e.target.result);\n    else importCSVText(e.target.result);\n    input.value = '';\n  };\n  reader.readAsText(file, 'utf-8');\n}\n\nfunction importJSON(text) {\n  try {\n    const data = JSON.parse(text);\n    const arr = Array.isArray(data) ? data : data.records || [];\n    let count = 0;\n    arr.forEach(r => {\n      if (r.systolic &amp;&amp; r.diastolic) {\n        addRecord({ sys: r.systolic, dia: r.diastolic,\n          pulse: r.pulse || r.heartRate || 0,\n          time: r.datetime || r.time || new Date().toISOString(),\n          note: r.note || '' });\n        count++;\n      }\n    });\n    toast(`\u5df2\u532f\u5165 ${count} \u7b46\u8a18\u9304`, 'success');\n  } catch { toast('JSON \u683c\u5f0f\u932f\u8aa4', 'error'); }\n}\n\nfunction importCSVText(text) {\n  const lines = text.replace(\/^\\uFEFF\/, '').trim().split('\\n');\n  let count = 0;\n  const header = lines[0].toLowerCase().split(',');\n  const idx = {\n    time:  header.findIndex(h => h.includes('datetime') || h.includes('time')),\n    sys:   header.findIndex(h => h.includes('systolic')),\n    dia:   header.findIndex(h => h.includes('diastolic')),\n    pulse: header.findIndex(h => h.includes('pulse')),\n    note:  header.findIndex(h => h.includes('note')),\n  };\n  for (let i = 1; i &lt; lines.length; i++) {\n    const cols = lines[i].split(',').map(c => c.replace(\/\"\/g, '').trim());\n    const sys = parseInt(cols[idx.sys]);\n    const dia = parseInt(cols[idx.dia]);\n    if (!sys || !dia) continue;\n    addRecord({ sys, dia, pulse: parseInt(cols[idx.pulse]) || 0,\n      time: cols[idx.time] || new Date().toISOString(),\n      note: cols[idx.note] || '' });\n    count++;\n  }\n  toast(`\u5df2\u532f\u5165 ${count} \u7b46\u8a18\u9304`, 'success');\n}\n\n\/\/ \u2500\u2500 BLE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\/\/ Omron \u79c1\u6709 BLE \u5354\u5b9a\uff08\u9006\u5411\u81ea omblepy \/ hass-omron\uff09\nconst OMRON_SERVICE_UUID = 'ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b';\nconst OMRON_UNLOCK_UUID  = 'b305b680-aee7-11e1-a730-0002a5d5c51b';\nconst OMRON_TX_UUIDS = [\n  'db5b55e0-aee7-11e1-965e-0002a5d5c51b',\n  'e0b8a060-aee7-11e1-92f4-0002a5d5c51b',\n  '0ae12b00-aee8-11e1-a192-0002a5d5c51b',\n  '10e1ba60-aee8-11e1-89e5-0002a5d5c51b',\n];\nconst OMRON_RX_UUIDS = [\n  '49123040-aee8-11e1-a74d-0002a5d5c51b',\n  '4d0bf320-aee8-11e1-a0d9-0002a5d5c51b',\n  '5128ce60-aee8-11e1-b84b-0002a5d5c51b',\n  '560f1420-aee8-11e1-8184-0002a5d5c51b',\n];\n\/\/ \u914d\u5c0d\u91d1\u9470\uff08\u8207 omblepy \u9810\u8a2d\u76f8\u540c\uff09\nconst PAIRING_KEY = new Uint8Array([\n  0xde,0xad,0xbe,0xaf,0x12,0x34,0x12,0x34,\n  0xde,0xad,0xbe,0xaf,0x12,0x34,0x12,0x34\n]);\n\nfunction bleLog(msg, type = '') {\n  const el = document.getElementById('ble-log');\n  const span = document.createElement('span');\n  span.className = type ? `log-${type}` : '';\n  span.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\\n`;\n  el.appendChild(span);\n  el.scrollTop = el.scrollHeight;\n}\n\nfunction setBleState(state) {\n  const dot = document.getElementById('ble-dot');\n  const txt = document.getElementById('ble-status-text');\n  dot.className = 'ble-dot ' + (state === 'connected' ? 'connected'\n    : state === 'connecting' ? 'connecting' : '');\n  txt.textContent = state === 'connected' ? '\u5df2\u9023\u63a5'\n    : state === 'connecting' ? '\u9023\u63a5\u4e2d\u2026' : '\u672a\u9023\u63a5';\n  document.getElementById('btn-download').disabled = state !== 'connected';\n  document.getElementById('btn-disconnect').disabled = state !== 'connected';\n  document.getElementById('btn-scan').disabled = state === 'connecting';\n}\n\nasync function bleScan() {\n  if (!navigator.bluetooth) { toast('\u6b64\u700f\u89bd\u5668\u4e0d\u652f\u63f4 Web Bluetooth', 'error'); return; }\n  try {\n    setBleState('connecting');\n    bleLog('\u6383\u63cf\u85cd\u7259\u88dd\u7f6e\u4e2d\u2026', 'info');\n    bleDevice = await navigator.bluetooth.requestDevice({\n      filters: [\n        { namePrefix: 'BLEsmart' }, { namePrefix: 'BP' }, { namePrefix: 'HEM' },\n        { services: [OMRON_SERVICE_UUID] }, { services: ['blood_pressure'] },\n      ],\n      optionalServices: [OMRON_SERVICE_UUID, 'blood_pressure', 'device_information', 'current_time']\n    });\n    bleLog(`\u627e\u5230\u88dd\u7f6e\uff1a${bleDevice.name}`, 'ok');\n    bleDevice.addEventListener('gattserverdisconnected', onBleDisconnect);\n    bleLog('\u6b63\u5728\u9023\u63a5 GATT \u4f3a\u670d\u5668\u2026', 'info');\n    bleServer = await bleDevice.gatt.connect();\n    bleLog('GATT \u9023\u63a5\u6210\u529f', 'ok');\n    setBleState('connected');\n    toast('\u85cd\u7259\u9023\u63a5\u6210\u529f', 'success');\n  } catch (err) {\n    setBleState('');\n    bleDevice = null; bleServer = null;\n    if (err.name === 'NotFoundError') bleLog('\u4f7f\u7528\u8005\u53d6\u6d88\u9078\u64c7', 'warn');\n    else bleLog(`\u9023\u63a5\u5931\u6557\uff1a${err.message}`, 'err');\n  }\n}\n\nasync function bleDownload() {\n  if (!bleServer || !bleServer.connected) { toast('\u88dd\u7f6e\u672a\u9023\u63a5', 'error'); return; }\n  try {\n    bleLog('\u63a2\u7d22 GATT \u670d\u52d9\u2026', 'info');\n    const services = await bleServer.getPrimaryServices();\n    const uuids = services.map(s => s.uuid);\n    bleLog(`\u767c\u73fe\u670d\u52d9\uff1a${uuids.join(', ')}`, 'info');\n\n    if (uuids.includes('00001810-0000-1000-8000-00805f9b34fb'))\n      await downloadStandardBP(bleServer);\n    else if (uuids.includes(OMRON_SERVICE_UUID))\n      await downloadOmronPrivate(bleServer);\n    else {\n      bleLog('\u672a\u77e5\u5354\u5b9a\uff0c\u9032\u5165\u5075\u932f\u6a21\u5f0f\u2026', 'warn');\n      await scanAllCharacteristics(bleServer, services);\n    }\n  } catch (err) { bleLog(`\u4e0b\u8f09\u5931\u6557\uff1a${err.message}`, 'err'); }\n}\n\n\/\/ \u6a19\u6e96 GATT Blood Pressure Profile\nasync function downloadStandardBP(server) {\n  bleLog('\u4f7f\u7528\u6a19\u6e96 Blood Pressure GATT Profile\u2026', 'info');\n  const service = await server.getPrimaryService('blood_pressure');\n  const char = await service.getCharacteristic('blood_pressure_measurement');\n  return new Promise(async (resolve) => {\n    let count = 0;\n    char.addEventListener('characteristicvaluechanged', (event) => {\n      const r = parseStandardBP(event.target.value);\n      if (r) {\n        addRecord({ sys: r.sys, dia: r.dia, pulse: r.pulse,\n          time: new Date().toISOString(), note: '\u85cd\u7259\u4e0b\u8f09\uff08GATT\uff09' });\n        bleLog(`\u8a18\u9304\uff1a${r.sys}\/${r.dia} mmHg, \u2665 ${r.pulse} bpm`, 'ok');\n        count++;\n      }\n    });\n    await char.startNotifications();\n    bleLog('\u7b49\u5f85\u91cf\u6e2c\u8cc7\u6599\uff08Indication\uff09\u2026', 'info');\n    setTimeout(() => {\n      char.stopNotifications().catch(() => {});\n      bleLog(`\u5b8c\u6210\uff0c\u5171\u4e0b\u8f09 ${count} \u7b46`, 'ok');\n      if (count > 0) { renderRecords(); toast(`\u4e0b\u8f09\u5b8c\u6210 ${count} \u7b46`, 'success'); }\n      resolve();\n    }, 30000);\n  });\n}\n\n\/\/ \u89e3\u6790\u6a19\u6e96 GATT \u8cc7\u6599\uff08IEEE 11073 sfloat\uff09\nfunction parseStandardBP(dataView) {\n  try {\n    const flags = dataView.getUint8(0);\n    const unit = (flags &amp; 0x01) ? 'kPa' : 'mmHg';\n    function sfloat(dv, offset) {\n      const raw = dv.getUint16(offset, true);\n      const exp = raw >> 12; const mantissa = raw &amp; 0x0FFF;\n      const e = exp >= 8 ? exp - 16 : exp;\n      const m = mantissa >= 0x800 ? mantissa - 0x1000 : mantissa;\n      return m * Math.pow(10, e);\n    }\n    const sys = Math.round(sfloat(dataView, 1));\n    const dia = Math.round(sfloat(dataView, 3));\n    const pulse = (flags &amp; 0x04) ? Math.round(sfloat(dataView, 14)) : 0;\n    if (unit === 'kPa') return { sys: Math.round(sys * 7.50062), dia: Math.round(dia * 7.50062), pulse };\n    return { sys, dia, pulse };\n  } catch { return null; }\n}\n\n\/\/ Omron \u79c1\u6709\u5354\u5b9a\uff08\u56db\u901a\u9053 TX\/RX\uff09\nasync function downloadOmronPrivate(server) {\n  bleLog('\u4f7f\u7528 Omron \u79c1\u6709\u5354\u5b9a\u2026', 'info');\n  const service = await server.getPrimaryService(OMRON_SERVICE_UUID);\n\n  \/\/ Step 1\uff1a\u5beb\u5165\u914d\u5c0d\u91d1\u9470\n  try {\n    const unlockChar = await service.getCharacteristic(OMRON_UNLOCK_UUID);\n    await unlockChar.writeValue(PAIRING_KEY);\n    bleLog('\u89e3\u9396\u91d1\u9470\u5beb\u5165\u6210\u529f', 'ok');\n  } catch (e) { bleLog(`\u89e3\u9396\u5931\u6557\uff08\u53ef\u80fd\u4e0d\u9700\u8981\uff09\uff1a${e.message}`, 'warn'); }\n\n  \/\/ Step 2\uff1a\u8a02\u95b1 RX Channels\n  const rxChars = [];\n  for (const uuid of OMRON_RX_UUIDS) {\n    try { rxChars.push(await service.getCharacteristic(uuid)); } catch { }\n  }\n  bleLog(`\u627e\u5230 ${rxChars.length} \u500b RX channel`, 'info');\n\n  let count = 0;\n  const received = [];\n  function onData(event) {\n    const data = new Uint8Array(event.target.value.buffer);\n    received.push(data);\n    const r = parseOmronRecord(data);\n    if (r) {\n      addRecord({ sys: r.sys, dia: r.dia, pulse: r.pulse, time: r.time, note: '\u85cd\u7259\u4e0b\u8f09\uff08Omron\uff09' });\n      bleLog(`\u8a18\u9304 ${r.time.slice(0,16)}\uff1a${r.sys}\/${r.dia} mmHg, \u2665 ${r.pulse} bpm`, 'ok');\n      count++;\n    }\n  }\n  for (const c of rxChars) { c.addEventListener('characteristicvaluechanged', onData); await c.startNotifications(); }\n\n  \/\/ Step 3\uff1a\u767c\u9001\u8b80\u53d6\u6307\u4ee4\n  const txChars = [];\n  for (const uuid of OMRON_TX_UUIDS) {\n    try { txChars.push(await service.getCharacteristic(uuid)); } catch { }\n  }\n  if (txChars.length > 0) {\n    bleLog('\u767c\u9001\u8cc7\u6599\u8b80\u53d6\u6307\u4ee4\u2026', 'info');\n    const cmd = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]);\n    try { await txChars[0].writeValue(cmd); } catch (e) { bleLog(`\u6307\u4ee4\u767c\u9001\u5931\u6557\uff1a${e.message}`, 'warn'); }\n  }\n\n  await new Promise(r => setTimeout(r, 8000));\n  for (const c of rxChars) {\n    try { await c.stopNotifications(); } catch { }\n    c.removeEventListener('characteristicvaluechanged', onData);\n  }\n  if (count > 0) { renderRecords(); toast(`\u4e0b\u8f09\u5b8c\u6210\uff0c\u5171 ${count} \u7b46`, 'success'); }\n  else {\n    bleLog('\u672a\u89e3\u6790\u5230\u8a18\u9304\uff0c\u539f\u59cb\u8cc7\u6599\u5df2\u5b58\u5165 console', 'warn');\n    bleLog('\u63d0\u793a\uff1a\u78ba\u8a8d\u8840\u58d3\u8a08\u986f\u793a \u25a0\uff08\u5df2\u914d\u5c0d\uff09\u800c\u975e -P-', 'info');\n    console.log('\u539f\u59cb BLE \u8cc7\u6599\uff1a', received);\n  }\n}\n\n\/\/ \u89e3\u6790 Omron \u79c1\u6709\u8cc7\u6599\u683c\u5f0f\nfunction parseOmronRecord(data) {\n  if (data.length &lt; 14) return null;\n  try {\n    const sys   = data[8]  | (data[9]  &lt;&lt; 8);\n    const dia   = data[10] | (data[11] &lt;&lt; 8);\n    const pulse = data[12] | (data[13] &lt;&lt; 8);\n    if (sys &lt; 60 || sys > 250 || dia &lt; 40 || dia > 150) return null;\n    const year = data[1] | (data[2] &lt;&lt; 8);\n    const time = (year > 2000 &amp;&amp; year &lt; 2100)\n      ? new Date(year, data[3]-1, data[4], data[5], data[6], data[7]).toISOString()\n      : new Date().toISOString();\n    return { sys, dia, pulse, time };\n  } catch { return null; }\n}\n\nasync function scanAllCharacteristics(server, services) {\n  for (const svc of services) {\n    const chars = await svc.getCharacteristics();\n    for (const c of chars) {\n      const p = c.properties;\n      bleLog(`  ${c.uuid} [${p.read?'R':''}${p.write?'W':''}${p.indicate?'I':''}${p.notify?'N':''}]`, 'info');\n    }\n  }\n}\n\nfunction onBleDisconnect() { setBleState(''); bleServer = null; bleLog('\u88dd\u7f6e\u5df2\u4e2d\u65b7\u9023\u7dda', 'warn'); }\nfunction bleDisconnect() {\n  if (bleDevice &amp;&amp; bleDevice.gatt.connected) bleDevice.gatt.disconnect();\n  setBleState(''); bleDevice = null; bleServer = null;\n}\n\n\/\/ \u2500\u2500 Toast \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction toast(msg, type = '') {\n  const el = document.getElementById('toast');\n  el.textContent = msg;\n  el.className = 'toast' + (type ? ` ${type}` : '');\n  el.classList.add('show');\n  clearTimeout(el._t);\n  el._t = setTimeout(() => el.classList.remove('show'), 2800);\n}\n\n\/\/ \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!navigator.bluetooth) document.getElementById('compat-warn').style.display = 'block';\n\n\/\/ \u8f09\u5165\u793a\u7bc4\u8cc7\u6599\nif (records.length === 0) {\n  const now = Date.now();\n  [[128,82,68,0],[135,88,72,-1],[122,78,65,-2],[142,92,75,-3],[118,76,70,-4],\n   [130,85,68,-5],[125,80,72,-6],[138,90,76,-7],[119,77,64,-8],[132,84,70,-9]]\n  .forEach(([sys,dia,pulse,d]) => {\n    addRecord({ sys, dia, pulse, time: new Date(now + d*86400000).toISOString(), note: '\u793a\u7bc4\u8cc7\u6599' });\n  });\n}\n\nrenderDashboard();\n&lt;\/script>\n&lt;\/body>\n&lt;\/html>\n<\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">6. \u7a0b\u5f0f\u78bc\u89e3\u8aaa<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">6.1 BLE \u5354\u5b9a\u81ea\u52d5\u5075\u6e2c<\/h3>\n\n\n\n<p>\u9023\u7dda\u5f8c\uff0c\u7a0b\u5f0f\u6703\u5148\u53d6\u5f97\u88dd\u7f6e\u6240\u6709 GATT \u670d\u52d9\u7684 UUID \u6e05\u55ae\uff0c\u518d\u6c7a\u5b9a\u4f7f\u7528\u54ea\u5957\u5354\u5b9a\uff1a<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">async function bleDownload() {\n  const services = await bleServer.getPrimaryServices();\n  const uuids = services.map(s => s.uuid);\n\n  if (uuids.includes('00001810-0000-1000-8000-00805f9b34fb'))\n    await downloadStandardBP(bleServer);      \/\/ \u6a19\u6e96 GATT\n  else if (uuids.includes(OMRON_SERVICE_UUID))\n    await downloadOmronPrivate(bleServer);    \/\/ Omron \u79c1\u6709\n  else\n    await scanAllCharacteristics(bleServer, services); \/\/ \u5075\u932f\u6a21\u5f0f\n}\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">6.2 IEEE 11073 sfloat \u89e3\u6790<\/h3>\n\n\n\n<p>\u6a19\u6e96 GATT \u8840\u58d3\u8cc7\u6599\u4f7f\u7528 IEEE 11073 \u7684 16-bit sfloat \u683c\u5f0f\uff08\u4e0d\u662f\u4e00\u822c\u7684 float32\uff09\uff0c\u89e3\u6790\u65b9\u5f0f\uff1a<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">function sfloat(dv, offset) {\n  const raw = dv.getUint16(offset, true);\n  const exp = raw >> 12;             \/\/ \u9ad8 4 bits = \u6307\u6578\n  const mantissa = raw &amp; 0x0FFF;    \/\/ \u4f4e 12 bits = \u5c3e\u6578\n  const e = exp >= 8 ? exp - 16 : exp;        \/\/ \u5e36\u7b26\u865f\u6307\u6578\n  const m = mantissa >= 0x800 ? mantissa - 0x1000 : mantissa; \/\/ \u5e36\u7b26\u865f\u5c3e\u6578\n  return m * Math.pow(10, e);\n}\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">6.3 Omron \u8cc7\u6599 byte \u89e3\u6790<\/h3>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">function parseOmronRecord(data) {\n  const sys   = data[8]  | (data[9]  &lt;&lt; 8);  \/\/ Little-endian uint16\n  const dia   = data[10] | (data[11] &lt;&lt; 8);\n  const pulse = data[12] | (data[13] &lt;&lt; 8);\n  const year  = data[1]  | (data[2]  &lt;&lt; 8);\n  const time  = new Date(year, data[3]-1, data[4],\n                         data[5], data[6], data[7]).toISOString();\n  return { sys, dia, pulse, time };\n}\n<\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">7. \u8e29\u5751\u7d00\u9304\u8207\u6ce8\u610f\u4e8b\u9805<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Web Bluetooth \u7684\u700f\u89bd\u5668\u9650\u5236<\/h3>\n\n\n\n<p><code>navigator.bluetooth<\/code> \u5728\u4ee5\u4e0b\u74b0\u5883<strong>\u4e0d\u5b58\u5728<\/strong>\uff1aFirefox\uff08\u5b8c\u5168\u4e0d\u652f\u63f4\uff09\u3001Safari \/ iOS\uff08\u4e0d\u652f\u63f4\uff09\u3001\u975e HTTPS \u7684\u7db2\u9801\uff08localhost \u9664\u5916\uff09\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Omron \u53ea\u80fd\u914d\u5c0d\u4e00\u53f0\u88dd\u7f6e<\/h3>\n\n\n\n<p>Omron \u8840\u58d3\u8a08\u4e00\u6b21\u53ea\u5141\u8a31\u8207\u4e00\u53f0\u88dd\u7f6e\u914d\u5c0d\u3002\u5982\u679c\u4f60\u4e4b\u524d\u5df2\u7528\u5b98\u65b9 Omron Connect App \u914d\u5c0d\uff0c\u5fc5\u9808\u5148\u5230\u624b\u6a5f\u85cd\u7259\u8a2d\u5b9a\u4e2d\u89e3\u9664\u914d\u5c0d\uff0c\u624d\u80fd\u8b93\u672c App \u9023\u7dda\u3002\u89e3\u9664\u5f8c\u9700\u91cd\u65b0\u9032\u884c\u300c\u9577\u6309\u85cd\u7259\u6309\u9215\u986f\u793a -P-\u300d\u7684\u914d\u5c0d\u6d41\u7a0b\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u914d\u5c0d\u91d1\u9470\u8207\u578b\u865f\u5dee\u7570<\/h3>\n\n\n\n<p>\u672c\u6587\u4f7f\u7528 omblepy \u7684\u9810\u8a2d\u91d1\u9470 <code>deadbeaf12341234deadbeaf12341234<\/code>\uff0c\u9019\u662f\u4e00\u500b\u4efb\u610f\u9078\u5b9a\u7684 16 bytes\uff0c\u5728\u914d\u5c0d\u6642\u5beb\u5165\u88dd\u7f6e EEPROM\u3002\u8f03\u65b0\u578b\u865f\uff08\u5982 HEM-7140T1\uff09\u4f7f\u7528 <code>FE4A<\/code> \u670d\u52d9\uff0cbyte \u683c\u5f0f\u4e0d\u540c\uff0c\u9700\u53e6\u884c\u7814\u7a76\u3002<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u5075\u932f\u65b9\u5f0f<\/h3>\n\n\n\n<p>\u5982\u679c\u4e0b\u8f09\u5f8c\u6c92\u6709\u89e3\u6790\u5230\u8a18\u9304\uff0c\u958b\u555f Chrome DevTools \u2192 Console\uff0c\u6703\u770b\u5230\u539f\u59cb\u7684 <code>Uint8Array<\/code> \u8cc7\u6599\u3002\u642d\u914d <a href=\"https:\/\/www.nordicsemi.com\/Products\/Development-tools\/nrf-connect-for-mobile\">nRF Connect<\/a> App \u6383\u63cf\u88dd\u7f6e\uff0c\u5c0d\u7167\u5be6\u969b\u7684 UUID \u548c\u8cc7\u6599\u503c\uff0c\u5c31\u80fd\u627e\u51fa\u6b63\u78ba\u7684 byte offset\u3002<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">8. \u5ef6\u4f38\u65b9\u5411<\/h2>\n\n\n\n<p>\u9019\u500b App \u76ee\u524d\u662f\u500b\u4eba\u4f7f\u7528\u7684\u6700\u5c0f\u53ef\u884c\u7248\u672c\uff0c\u6709\u5e7e\u500b\u660e\u986f\u53ef\u4ee5\u64f4\u5145\u7684\u65b9\u5411\uff1a<\/p>\n\n\n\n<p><strong>\u591a\u7528\u6236\u652f\u63f4<\/strong>\uff1aOmron \u8840\u58d3\u8a08\u652f\u63f4 User 1 \/ User 2 \u5169\u7d44\u8a18\u9304\uff0c\u8cc7\u6599\u683c\u5f0f\u4e2d\u6709 user ID \u6b04\u4f4d\uff0c\u53ef\u4ee5\u5206\u958b\u986f\u793a\u548c\u7d71\u8a08\u3002<\/p>\n\n\n\n<p><strong>\u96f2\u7aef\u540c\u6b65<\/strong>\uff1a\u76ee\u524d\u8cc7\u6599\u5b58\u5728 <code>localStorage<\/code>\uff0c\u53ea\u5728\u55ae\u4e00\u700f\u89bd\u5668\u53ef\u7528\u3002\u63a5\u4e00\u500b\u7c21\u55ae\u7684\u5f8c\u7aef\uff08Cloudflare Workers + D1 \u6216 Supabase\uff09\u5c31\u80fd\u8de8\u88dd\u7f6e\u540c\u6b65\u3002<\/p>\n\n\n\n<p><strong>PWA \u96e2\u7dda\u652f\u63f4<\/strong>\uff1a\u52a0\u5165 <code>manifest.json<\/code> \u548c Service Worker\uff0c\u5c31\u80fd\u5b89\u88dd\u5230\u684c\u9762\uff0c\u96e2\u7dda\u4e5f\u80fd\u4f7f\u7528\u3002<\/p>\n\n\n\n<p><strong>ESP32 \u85cd\u7259\u6a4b\u63a5<\/strong>\uff1a\u5982\u679c\u8981\u652f\u63f4 Safari \/ iOS\uff0c\u53ef\u4ee5\u7528 ESP32 \u4f5c\u70ba BLE-to-WebSocket \u6a4b\u63a5\u5668\uff08omblepy \u6709 semi-finished \u7684 ESP32 bridge \u7248\u672c\u53ef\u53c3\u8003\uff09\uff0cWeb App \u6539\u6210\u9023 WebSocket \u63a5\u6536\u8cc7\u6599\u3002<\/p>\n\n\n\n<p><strong>Home Assistant \u6574\u5408<\/strong>\uff1a\u5982\u679c\u4f60\u7528 Home Assistant\uff0c<a href=\"https:\/\/github.com\/eigger\/hass-omron\">eigger\/hass-omron<\/a> \u63d0\u4f9b\u73fe\u6210\u7684\u6574\u5408\uff0c\u53ef\u4ee5\u81ea\u52d5\u8a18\u9304\u5230 HA \u7684\u6b77\u53f2\u8cc7\u6599\u5eab\uff0c\u4e26\u5efa\u7acb Lovelace \u5100\u8868\u677f\u3002<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>\u672c\u6587\u6240\u6709\u7a0b\u5f0f\u78bc\u4ee5 MIT \u6388\u6b0a\u91cb\u51fa\uff0c\u6b61\u8fce\u81ea\u7531\u4f7f\u7528\u8207\u6539\u4f5c\u3002<\/p>\n\n\n\n<p>\u5982\u679c\u4f60\u7684 Omron \u578b\u865f\u4e0d\u5728\u652f\u63f4\u5217\u8868\u4e2d\uff0c\u6216\u662f\u9047\u5230\u9023\u7dda\u554f\u984c\uff0c\u6b61\u8fce\u7559\u8a00\u5206\u4eab\u4f60\u7684 nRF Connect \u6383\u63cf\u7d50\u679c\uff08Service UUID\u3001Characteristic UUID\uff09\uff0c\u4e00\u8d77\u5b8c\u5584\u9019\u4efd\u6587\u4ef6\u3002<\/p>\n<\/blockquote>\n","protected":false},"excerpt":{"rendered":"<p>\u8cb7\u4e86\u4e00\u53f0 Omron JPN616T \u8840\u58d3\u8a08\uff0c\u88dd\u7f6e\u4e0a\u6709\u500b\u85cd\u7259\u6309\u9215\uff0c\u5b98\u65b9 App  &hellip; <\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_import_markdown_pro_load_document_selector":0,"_import_markdown_pro_submit_text_textarea":"","fifu_image_url":"","fifu_image_alt":"","footnotes":""},"categories":[1],"tags":[282,286,285,284,283,287],"class_list":["post-15449","post","type-post","status-publish","format-standard","hentry","category-uncategorized","tag-ble","tag-bluetooth","tag-omron","tag-pwa","tag-web","tag-287"],"_links":{"self":[{"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/posts\/15449","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/comments?post=15449"}],"version-history":[{"count":1,"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/posts\/15449\/revisions"}],"predecessor-version":[{"id":15450,"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/posts\/15449\/revisions\/15450"}],"wp:attachment":[{"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/media?parent=15449"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/categories?post=15449"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/fgchen.com\/wpedu\/wp-json\/wp\/v2\/tags?post=15449"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}