Hezu06 commited on
Commit
15b4b9b
·
1 Parent(s): 17205e5

Add BLE device connection UI and quick BPM zone selection

Browse files
Files changed (1) hide show
  1. index.html +452 -37
index.html CHANGED
@@ -172,6 +172,30 @@
172
  text-align: left;
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  /* ---- NOTICE ---- */
176
  .notice {
177
  background: #0f2040;
@@ -184,6 +208,147 @@
184
  text-align: left;
185
  }
186
  .notice strong { color: #e94560; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  </style>
188
  </head>
189
  <body>
@@ -201,24 +366,107 @@
201
 
202
  <!-- TAB: BLE -->
203
  <div id="tab-ble" class="tab-panel active">
204
- <div class="notice">
205
- <strong>Lưu ý Xiaomi / Samsung:</strong> Đồng hồ thông minh dùng giao thức riêng,
206
- không truyền nhịp tim qua BLE chuẩn. Thử kết nối — nếu không ra BPM,
207
- hãy dùng tab <em>Nhập thủ công</em> hoặc một chest-strap HR monitor (Polar, Garmin…).
 
 
 
 
 
 
 
 
208
  </div>
209
- <div class="button-group">
210
- <button id="btn-connect">1. Kết nối đồng hồ</button>
 
 
 
 
 
211
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  </div>
213
 
214
  <!-- TAB: MANUAL -->
215
  <div id="tab-manual" class="tab-panel">
216
- <p style="color:#a2a2bd; font-size:14px; margin-bottom:10px;">
217
- Nhập BPM từ app đồng hồ (Mi Fitness, Samsung Health…) rồi nhấn Set.
218
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  <div class="manual-group">
220
- <input type="number" id="manual-bpm-input" min="30" max="220" value="75" />
221
- <button id="btn-manual-set" onclick="setManualBpm()">Set BPM</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  </div>
223
  </div>
224
 
@@ -322,6 +570,32 @@
322
  applyBpm(val, 'Nhập thủ công');
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  // ===============================
326
  // PARSE CHUẨN BLE Heart Rate (0x2A37)
327
  // ===============================
@@ -371,10 +645,76 @@
371
  }
372
  }
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  // ===============================
375
  // BLE CONNECT — auto scan tất cả services
376
  // ===============================
377
- btnConnect.addEventListener('click', async () => {
378
  if (!navigator.bluetooth) {
379
  setStatus('Trình duyệt không hỗ trợ Web Bluetooth!', 'err');
380
  return;
@@ -384,49 +724,47 @@
384
  setStatus('Đang mở hộp thoại BLE…', 'info');
385
  log('Bắt đầu scan BLE...');
386
 
 
 
 
 
387
  const device = await navigator.bluetooth.requestDevice({
388
  acceptAllDevices: true,
389
  optionalServices: [
390
- // Chuẩn BLE
391
- 'heart_rate',
392
- 'battery_service',
393
- 'device_information',
394
- 'generic_access',
395
- // Xiaomi Mi Band 4/5/6/7 — proprietary
396
  '0000fee0-0000-1000-8000-00805f9b34fb',
397
  '0000fee1-0000-1000-8000-00805f9b34fb',
398
- // Xiaomi Mi Band 7 / Amazfit
399
  '0000181d-0000-1000-8000-00805f9b34fb',
400
- // Samsung Galaxy Watch — proprietary health
401
  '0000fd00-0000-1000-8000-00805f9b34fb',
402
- // Generic HR secondary
403
  '0000180d-0000-1000-8000-00805f9b34fb',
404
  ]
405
  });
406
 
 
407
  log(`Device: ${device.name || '(no name)'} id=${device.id}`);
 
408
  setStatus('Đang kết nối GATT…', 'info');
409
 
410
- const server = await device.gatt.connect();
411
- const services = await server.getPrimaryServices();
 
 
 
412
  log(`Tìm thấy ${services.length} service(s)`);
413
 
 
 
414
  let subscribed = 0;
415
 
416
  for (const svc of services) {
417
  log(`Service: ${svc.uuid}`);
418
  let chars;
419
- try {
420
- chars = await svc.getCharacteristics();
421
- } catch (e) {
422
- log(` Không đọc được characteristics: ${e.message}`);
423
- continue;
424
- }
425
 
426
  for (const char of chars) {
427
  const props = char.properties;
428
  log(` Char: ${char.uuid} [notify=${props.notify} indicate=${props.indicate} read=${props.read}]`);
429
-
430
  if (props.notify || props.indicate) {
431
  try {
432
  await char.startNotifications();
@@ -434,27 +772,104 @@
434
  (ev) => handleCharData(ev, char.uuid, svc.uuid));
435
  log(` ✅ Subscribed: ${char.uuid}`);
436
  subscribed++;
437
- } catch (e) {
438
- log(` ❌ Subscribe failed: ${e.message}`);
439
- }
440
  }
441
  }
442
  }
443
 
444
- btnConnect.textContent = '✓ BLE Connected';
445
- btnConnect.disabled = true;
446
 
447
  if (subscribed === 0) {
448
- setStatus('Kết nối OK nhưng không characteristic nào để subscribe. Thử tab Nhập thủ công.', 'err');
 
 
 
 
449
  } else {
450
- setStatus(`Đã subscribe ${subscribed} characteristic(s), đang chờ BPM…`, 'ok');
 
 
 
 
451
  }
452
 
453
  } catch (err) {
454
  log(`Lỗi: ${err.message}`);
455
  setStatus('Lỗi BLE: ' + err.message, 'err');
 
456
  }
457
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
  // ===============================
460
  // GENERATE MUSIC
 
172
  text-align: left;
173
  }
174
 
175
+ /* ---- QUICK BPM ZONES ---- */
176
+ .zone-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin:12px 0; }
177
+ .zone-btn { background:#0f3460; border:1px solid #1a3a6e; border-radius:10px; color:#fff;
178
+ padding:10px 4px; font-size:13px; cursor:pointer; transition:0.2s;
179
+ display:flex; flex-direction:column; align-items:center; gap:3px; }
180
+ .zone-btn:hover { background:#1a4070; border-color:#4ecca3; }
181
+ .zone-btn.active-zone { border-color:#4ecca3; background:#0a2844; }
182
+ .zone-btn .zone-emoji { font-size:20px; }
183
+ .zone-btn .zone-label { font-size:10px; color:#a2a2bd; }
184
+ .zone-btn .zone-bpm { font-size:12px; color:#4ecca3; font-weight:bold; }
185
+ .auto-row { display:flex; align-items:center; justify-content:center; gap:10px;
186
+ margin-top:8px; font-size:13px; color:#a2a2bd; }
187
+ .toggle-wrap { position:relative; width:36px; height:20px; }
188
+ .toggle-wrap input { opacity:0; width:100%; height:100%; position:absolute; cursor:pointer; margin:0; z-index:2; }
189
+ .toggle-track { position:absolute; inset:0; background:#1a3a6e; border-radius:10px; transition:background 0.2s; }
190
+ .toggle-wrap input:checked ~ .toggle-track { background:#4ecca3; }
191
+ .toggle-thumb { position:absolute; top:3px; left:3px; width:14px; height:14px;
192
+ background:#fff; border-radius:50%; transition:transform 0.2s; pointer-events:none; }
193
+ .toggle-wrap input:checked ~ .toggle-thumb { transform:translateX(16px); }
194
+ #refresh-interval-wrap { display:none; align-items:center; gap:6px; }
195
+ #refresh-interval-wrap.visible { display:flex; }
196
+ #refresh-sec { width:52px; background:#0f3460; border:1px solid #4ecca3;
197
+ border-radius:6px; color:#fff; font-size:14px; text-align:center; padding:3px; }
198
+
199
  /* ---- NOTICE ---- */
200
  .notice {
201
  background: #0f2040;
 
208
  text-align: left;
209
  }
210
  .notice strong { color: #e94560; }
211
+
212
+ /* ---- BLE DEVICE CARD ---- */
213
+ #ble-idle { }
214
+ #ble-card { display:none; }
215
+
216
+ .scan-hint {
217
+ font-size: 12px; color: #4a5a7a; margin-top: 8px; font-style: italic;
218
+ }
219
+
220
+ /* Scanning animation */
221
+ .scan-ring-wrap {
222
+ display: flex; align-items: center; justify-content: center;
223
+ gap: 14px; margin: 12px 0 8px;
224
+ }
225
+ .scan-ring {
226
+ width: 44px; height: 44px; border-radius: 50%;
227
+ border: 3px solid transparent;
228
+ border-top-color: #4ecca3;
229
+ border-right-color: #4ecca366;
230
+ animation: spin 1s linear infinite;
231
+ }
232
+ @keyframes spin { to { transform: rotate(360deg); } }
233
+ .scan-label { color: #4ecca3; font-size: 14px; font-weight: bold; }
234
+
235
+ /* Connected device card */
236
+ .device-card {
237
+ background: linear-gradient(135deg, #0a2040 0%, #0f3060 100%);
238
+ border: 1px solid #4ecca355;
239
+ border-radius: 14px;
240
+ padding: 16px 18px;
241
+ margin: 10px 0;
242
+ display: flex;
243
+ align-items: center;
244
+ gap: 14px;
245
+ text-align: left;
246
+ position: relative;
247
+ overflow: hidden;
248
+ }
249
+ .device-card::before {
250
+ content: '';
251
+ position: absolute;
252
+ inset: 0;
253
+ background: radial-gradient(ellipse at 20% 50%, #4ecca318 0%, transparent 60%);
254
+ pointer-events: none;
255
+ }
256
+ .device-icon-wrap {
257
+ width: 52px; height: 52px; border-radius: 50%;
258
+ background: linear-gradient(135deg, #0f3460, #1a4a80);
259
+ border: 2px solid #4ecca355;
260
+ display: flex; align-items: center; justify-content: center;
261
+ font-size: 26px; flex-shrink: 0;
262
+ }
263
+ .device-info { flex: 1; min-width: 0; }
264
+ .device-name {
265
+ color: #fff; font-size: 15px; font-weight: bold;
266
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
267
+ }
268
+ .device-sub {
269
+ color: #a2a2bd; font-size: 12px; margin-top: 2px;
270
+ }
271
+ .device-badges {
272
+ display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
273
+ }
274
+ .badge {
275
+ background: #0f3460; border: 1px solid #1a4a80;
276
+ border-radius: 20px; padding: 2px 9px;
277
+ font-size: 11px; color: #a2a2bd;
278
+ }
279
+ .badge.connected {
280
+ background: #0a3020; border-color: #4ecca355; color: #4ecca3;
281
+ }
282
+ .badge.subscribing {
283
+ background: #2a1a00; border-color: #e9a02055; color: #e9a020;
284
+ }
285
+ .badge.no-hr {
286
+ background: #300a0a; border-color: #e9456055; color: #e94560;
287
+ }
288
+
289
+ /* Signal bars */
290
+ .signal-wrap {
291
+ display: flex; align-items: flex-end; gap: 2px; height: 18px;
292
+ }
293
+ .sig-bar {
294
+ width: 4px; border-radius: 2px; background: #4ecca3;
295
+ opacity: 0.25; transition: opacity 0.4s;
296
+ }
297
+ .sig-bar.active { opacity: 1; }
298
+ .sig-bar:nth-child(1) { height: 5px; }
299
+ .sig-bar:nth-child(2) { height: 9px; }
300
+ .sig-bar:nth-child(3) { height: 13px; }
301
+ .sig-bar:nth-child(4) { height: 18px; }
302
+
303
+ /* Disconnect button */
304
+ #btn-disconnect {
305
+ background: transparent;
306
+ border: 1px solid #e9456066;
307
+ color: #e94560;
308
+ padding: 6px 13px;
309
+ font-size: 12px;
310
+ border-radius: 8px;
311
+ cursor: pointer;
312
+ font-weight: bold;
313
+ transition: 0.2s;
314
+ flex-shrink: 0;
315
+ }
316
+ #btn-disconnect:hover {
317
+ background: #e9456020; border-color: #e94560;
318
+ }
319
+
320
+ /* Reconnect button (shown when disconnected) */
321
+ #btn-reconnect {
322
+ display: none;
323
+ background: #4ecca322;
324
+ border: 1px solid #4ecca355;
325
+ color: #4ecca3;
326
+ padding: 6px 13px;
327
+ font-size: 12px;
328
+ border-radius: 8px;
329
+ cursor: pointer;
330
+ font-weight: bold;
331
+ transition: 0.2s;
332
+ flex-shrink: 0;
333
+ }
334
+ #btn-reconnect:hover { background: #4ecca344; }
335
+
336
+ /* Pulse dot (connection alive indicator) */
337
+ .pulse-dot {
338
+ width: 8px; height: 8px; border-radius: 50%;
339
+ background: #4ecca3;
340
+ box-shadow: 0 0 0 0 #4ecca3aa;
341
+ animation: pulseDot 1.8s ease infinite;
342
+ flex-shrink: 0;
343
+ }
344
+ @keyframes pulseDot {
345
+ 0% { box-shadow: 0 0 0 0 #4ecca3aa; }
346
+ 70% { box-shadow: 0 0 0 7px #4ecca300; }
347
+ 100% { box-shadow: 0 0 0 0 #4ecca300; }
348
+ }
349
+ .pulse-dot.dead {
350
+ background: #e94560; box-shadow: none; animation: none;
351
+ }
352
  </style>
353
  </head>
354
  <body>
 
366
 
367
  <!-- TAB: BLE -->
368
  <div id="tab-ble" class="tab-panel active">
369
+
370
+ <!-- STATE: idle / scanning -->
371
+ <div id="ble-idle">
372
+ <div class="notice">
373
+ <strong>Galaxy Watch / Xiaomi / Huawei:</strong> Các đồng hồ này dùng giao thức riêng,
374
+ <em>không expose Heart Rate qua Web Bluetooth</em>.
375
+ Dùng tab <strong>Nhập thủ công</strong> hoặc chest-strap Polar/Garmin.
376
+ </div>
377
+ <div class="button-group">
378
+ <button id="btn-connect">📡 Quét thiết bị BLE</button>
379
+ </div>
380
+ <p class="scan-hint">Trình duyệt sẽ mở hộp thoại chọn thiết bị Bluetooth</p>
381
  </div>
382
+
383
+ <!-- STATE: scanning spinner (shown briefly) -->
384
+ <div id="ble-scanning" style="display:none; padding:8px 0 4px;">
385
+ <div class="scan-ring-wrap">
386
+ <div class="scan-ring"></div>
387
+ <span class="scan-label">Đang kết nối…</span>
388
+ </div>
389
  </div>
390
+
391
+ <!-- STATE: connected device card -->
392
+ <div id="ble-card" style="display:none;">
393
+ <div class="device-card">
394
+ <div class="device-icon-wrap" id="dev-icon">⌚</div>
395
+ <div class="device-info">
396
+ <div class="device-name" id="dev-name">Unknown Device</div>
397
+ <div class="device-sub" id="dev-sub">Đang kiểm tra characteristics…</div>
398
+ <div class="device-badges">
399
+ <span class="badge connected" id="badge-conn">● Đã kết nối</span>
400
+ <span class="badge" id="badge-hr" style="display:none"></span>
401
+ <span class="badge" id="badge-svc"></span>
402
+ </div>
403
+ </div>
404
+ <div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
405
+ <div class="signal-wrap" id="signal-bars">
406
+ <div class="sig-bar active"></div>
407
+ <div class="sig-bar active"></div>
408
+ <div class="sig-bar active"></div>
409
+ <div class="sig-bar"></div>
410
+ </div>
411
+ <div class="pulse-dot" id="pulse-dot"></div>
412
+ </div>
413
+ <div style="display:flex; flex-direction:column; gap:6px; align-items:flex-end;">
414
+ <button id="btn-disconnect">Ngắt kết nối</button>
415
+ <button id="btn-reconnect">🔄 Kết nối lại</button>
416
+ </div>
417
+ </div>
418
+ </div>
419
+
420
  </div>
421
 
422
  <!-- TAB: MANUAL -->
423
  <div id="tab-manual" class="tab-panel">
424
+ <p style="color:#a2a2bd; font-size:13px; margin-bottom:6px;">
425
+ Nhìn BPM trên Samsung Health chọn vùng hoặc nhập chính xác bên dưới.
426
  </p>
427
+
428
+ <!-- Quick zone buttons -->
429
+ <div class="zone-grid">
430
+ <button class="zone-btn" onclick="setZone(55,'Nghỉ ngơi')">
431
+ <span class="zone-emoji">😴</span>
432
+ <span class="zone-label">Nghỉ ngơi</span>
433
+ <span class="zone-bpm">~55 BPM</span>
434
+ </button>
435
+ <button class="zone-btn" onclick="setZone(70,'Nhẹ nhàng')">
436
+ <span class="zone-emoji">🚶</span>
437
+ <span class="zone-label">Nhẹ nhàng</span>
438
+ <span class="zone-bpm">~70 BPM</span>
439
+ </button>
440
+ <button class="zone-btn" onclick="setZone(95,'Vận động')">
441
+ <span class="zone-emoji">🏃</span>
442
+ <span class="zone-label">Vận động</span>
443
+ <span class="zone-bpm">~95 BPM</span>
444
+ </button>
445
+ <button class="zone-btn" onclick="setZone(130,'Cường độ cao')">
446
+ <span class="zone-emoji">🔥</span>
447
+ <span class="zone-label">Cường độ cao</span>
448
+ <span class="zone-bpm">~130 BPM</span>
449
+ </button>
450
+ </div>
451
+
452
+ <!-- Precise input -->
453
  <div class="manual-group">
454
+ <input type="number" id="manual-bpm-input" min="30" max="220" value="75"
455
+ onkeydown="if(event.key==='Enter') setManualBpm()" />
456
+ <button id="btn-manual-set" onclick="setManualBpm()">Áp dụng</button>
457
+ </div>
458
+
459
+ <!-- Auto refresh reminder -->
460
+ <div class="auto-row">
461
+ <label class="toggle-wrap">
462
+ <input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh(this.checked)">
463
+ <span class="toggle-track"></span>
464
+ <span class="toggle-thumb"></span>
465
+ </label>
466
+ <span>Nhắc cập nhật BPM mỗi</span>
467
+ <span id="refresh-interval-wrap">
468
+ <input type="number" id="refresh-sec" min="10" max="300" value="30"> giây
469
+ </span>
470
  </div>
471
  </div>
472
 
 
570
  applyBpm(val, 'Nhập thủ công');
571
  }
572
 
573
+ function setZone(bpm, label) {
574
+ document.getElementById('manual-bpm-input').value = bpm;
575
+ bpmHistory = [];
576
+ applyBpm(bpm, label);
577
+ // Highlight active zone
578
+ document.querySelectorAll('.zone-btn').forEach(b => b.classList.remove('active-zone'));
579
+ event.currentTarget.classList.add('active-zone');
580
+ }
581
+
582
+ let autoRefreshTimer = null;
583
+ function toggleAutoRefresh(on) {
584
+ const wrap = document.getElementById('refresh-interval-wrap');
585
+ wrap.classList.toggle('visible', on);
586
+ if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
587
+ if (on) {
588
+ const sec = parseInt(document.getElementById('refresh-sec').value, 10) || 30;
589
+ autoRefreshTimer = setInterval(() => {
590
+ setStatus(`⏰ Hãy kiểm tra BPM trên Samsung Health và cập nhật!`, 'info');
591
+ // Flash BPM display
592
+ bpmDisplay.style.opacity = '0.4';
593
+ setTimeout(() => bpmDisplay.style.opacity = '1', 600);
594
+ }, sec * 1000);
595
+ setStatus(`Sẽ nhắc cập nhật BPM mỗi ${sec} giây`, 'ok');
596
+ }
597
+ }
598
+
599
  // ===============================
600
  // PARSE CHUẨN BLE Heart Rate (0x2A37)
601
  // ===============================
 
645
  }
646
  }
647
 
648
+ // ===============================
649
+ // BLE STATE
650
+ // ===============================
651
+ let bleDevice = null;
652
+ let bleServer = null;
653
+ let bleSubCount = 0;
654
+
655
+ const bleIdle = document.getElementById('ble-idle');
656
+ const bleScanning = document.getElementById('ble-scanning');
657
+ const bleCard = document.getElementById('ble-card');
658
+ const devIcon = document.getElementById('dev-icon');
659
+ const devName = document.getElementById('dev-name');
660
+ const devSub = document.getElementById('dev-sub');
661
+ const badgeConn = document.getElementById('badge-conn');
662
+ const badgeHr = document.getElementById('badge-hr');
663
+ const badgeSvc = document.getElementById('badge-svc');
664
+ const pulseDot = document.getElementById('pulse-dot');
665
+ const btnDisconnect = document.getElementById('btn-disconnect');
666
+ const btnReconnect = document.getElementById('btn-reconnect');
667
+
668
+ function guessDeviceIcon(name) {
669
+ if (!name) return '📡';
670
+ const n = name.toLowerCase();
671
+ if (n.includes('polar') || n.includes('hrm') || n.includes('chest')) return '💓';
672
+ if (n.includes('garmin')) return '🏃';
673
+ if (n.includes('galaxy') || n.includes('watch') || n.includes('samsung')) return '⌚';
674
+ if (n.includes('mi band') || n.includes('xiaomi') || n.includes('amazfit')) return '📿';
675
+ if (n.includes('fitbit')) return '💪';
676
+ return '⌚';
677
+ }
678
+
679
+ function showBleCard(name, subText) {
680
+ bleIdle.style.display = 'none';
681
+ bleScanning.style.display = 'none';
682
+ bleCard.style.display = 'block';
683
+ devIcon.textContent = guessDeviceIcon(name);
684
+ devName.textContent = name || 'Unknown Device';
685
+ devSub.textContent = subText || '';
686
+ }
687
+
688
+ function setBleConnected() {
689
+ badgeConn.textContent = '● Đã kết nối';
690
+ badgeConn.className = 'badge connected';
691
+ pulseDot.classList.remove('dead');
692
+ btnDisconnect.style.display = 'inline-block';
693
+ btnReconnect.style.display = 'none';
694
+ }
695
+
696
+ function setBleDisconnected() {
697
+ badgeConn.textContent = '○ Mất kết nối';
698
+ badgeConn.className = 'badge no-hr';
699
+ pulseDot.classList.add('dead');
700
+ btnDisconnect.style.display = 'none';
701
+ btnReconnect.style.display = 'inline-block';
702
+ // Animate signal bars off
703
+ document.querySelectorAll('.sig-bar').forEach(b => b.classList.remove('active'));
704
+ }
705
+
706
+ function resetBleUI() {
707
+ bleCard.style.display = 'none';
708
+ bleScanning.style.display = 'none';
709
+ bleIdle.style.display = 'block';
710
+ badgeHr.style.display = 'none';
711
+ bleDevice = null; bleServer = null; bleSubCount = 0;
712
+ }
713
+
714
  // ===============================
715
  // BLE CONNECT — auto scan tất cả services
716
  // ===============================
717
+ async function doBleConnect() {
718
  if (!navigator.bluetooth) {
719
  setStatus('Trình duyệt không hỗ trợ Web Bluetooth!', 'err');
720
  return;
 
724
  setStatus('Đang mở hộp thoại BLE…', 'info');
725
  log('Bắt đầu scan BLE...');
726
 
727
+ // Show scanning state while picker is open
728
+ bleIdle.style.display = 'none';
729
+ bleScanning.style.display = 'block';
730
+
731
  const device = await navigator.bluetooth.requestDevice({
732
  acceptAllDevices: true,
733
  optionalServices: [
734
+ 'heart_rate', 'battery_service', 'device_information', 'generic_access',
 
 
 
 
 
735
  '0000fee0-0000-1000-8000-00805f9b34fb',
736
  '0000fee1-0000-1000-8000-00805f9b34fb',
 
737
  '0000181d-0000-1000-8000-00805f9b34fb',
 
738
  '0000fd00-0000-1000-8000-00805f9b34fb',
 
739
  '0000180d-0000-1000-8000-00805f9b34fb',
740
  ]
741
  });
742
 
743
+ bleDevice = device;
744
  log(`Device: ${device.name || '(no name)'} id=${device.id}`);
745
+ showBleCard(device.name, 'Đang kết nối GATT…');
746
  setStatus('Đang kết nối GATT…', 'info');
747
 
748
+ // Listen for unexpected disconnects
749
+ device.addEventListener('gattserverdisconnected', onGattDisconnected);
750
+
751
+ bleServer = await device.gatt.connect();
752
+ const services = await bleServer.getPrimaryServices();
753
  log(`Tìm thấy ${services.length} service(s)`);
754
 
755
+ badgeSvc.textContent = `${services.length} services`;
756
+
757
  let subscribed = 0;
758
 
759
  for (const svc of services) {
760
  log(`Service: ${svc.uuid}`);
761
  let chars;
762
+ try { chars = await svc.getCharacteristics(); }
763
+ catch (e) { log(` Không đọc được chars: ${e.message}`); continue; }
 
 
 
 
764
 
765
  for (const char of chars) {
766
  const props = char.properties;
767
  log(` Char: ${char.uuid} [notify=${props.notify} indicate=${props.indicate} read=${props.read}]`);
 
768
  if (props.notify || props.indicate) {
769
  try {
770
  await char.startNotifications();
 
772
  (ev) => handleCharData(ev, char.uuid, svc.uuid));
773
  log(` ✅ Subscribed: ${char.uuid}`);
774
  subscribed++;
775
+ } catch (e) { log(` ❌ Subscribe failed: ${e.message}`); }
 
 
776
  }
777
  }
778
  }
779
 
780
+ bleSubCount = subscribed;
781
+ setBleConnected();
782
 
783
  if (subscribed === 0) {
784
+ badgeHr.textContent = ' KhôngHR';
785
+ badgeHr.className = 'badge no-hr';
786
+ badgeHr.style.display = 'inline-block';
787
+ devSub.textContent = 'Không đọc được HR — thử tab Nhập thủ công';
788
+ setStatus('Kết nối OK nhưng thiết bị không stream HR. Thử tab Nhập thủ công.', 'err');
789
  } else {
790
+ badgeHr.textContent = ` ${subscribed} HR char`;
791
+ badgeHr.className = 'badge subscribing';
792
+ badgeHr.style.display = 'inline-block';
793
+ devSub.textContent = `Đã subscribe ${subscribed} characteristic — đang chờ BPM…`;
794
+ setStatus(`Đã subscribe ${subscribed} characteristic, đang chờ BPM…`, 'ok');
795
  }
796
 
797
  } catch (err) {
798
  log(`Lỗi: ${err.message}`);
799
  setStatus('Lỗi BLE: ' + err.message, 'err');
800
+ resetBleUI();
801
  }
802
+ }
803
+
804
+ // ===============================
805
+ // GATT DISCONNECTED EVENT
806
+ // ===============================
807
+ function onGattDisconnected() {
808
+ log('⚠ GATT server disconnected (đồng hồ tự ngắt hoặc ra khỏi tầm)');
809
+ setStatus('⚠ Mất kết nối BLE — nhấn "Kết nối lại" để thử lại.', 'err');
810
+ setBleDisconnected();
811
+ devSub.textContent = 'Mất kết nối — ra khỏi tầm Bluetooth?';
812
+ }
813
+
814
+ // ===============================
815
+ // DISCONNECT
816
+ // ===============================
817
+ function doDisconnect() {
818
+ if (bleDevice && bleDevice.gatt.connected) {
819
+ bleDevice.gatt.disconnect();
820
+ log('Đã ngắt kết nối BLE theo yêu cầu');
821
+ }
822
+ setBleDisconnected();
823
+ devSub.textContent = 'Đã ngắt kết nối';
824
+ setStatus('Đã ngắt kết nối BLE.', 'info');
825
+ }
826
+
827
+ // ===============================
828
+ // RECONNECT
829
+ // ===============================
830
+ async function doReconnect() {
831
+ if (!bleDevice) { resetBleUI(); return; }
832
+ try {
833
+ devSub.textContent = 'Đang kết nối lại…';
834
+ btnReconnect.disabled = true;
835
+ setStatus('Đang kết nối lại GATT…', 'info');
836
+
837
+ bleServer = await bleDevice.gatt.connect();
838
+ const services = await bleServer.getPrimaryServices();
839
+ let subscribed = 0;
840
+
841
+ for (const svc of services) {
842
+ let chars;
843
+ try { chars = await svc.getCharacteristics(); } catch { continue; }
844
+ for (const char of chars) {
845
+ const props = char.properties;
846
+ if (props.notify || props.indicate) {
847
+ try {
848
+ await char.startNotifications();
849
+ char.addEventListener('characteristicvaluechanged',
850
+ (ev) => handleCharData(ev, char.uuid, svc.uuid));
851
+ subscribed++;
852
+ } catch {}
853
+ }
854
+ }
855
+ }
856
+
857
+ bleSubCount = subscribed;
858
+ setBleConnected();
859
+ devSub.textContent = `Kết nối lại thành công — ${subscribed} char`;
860
+ setStatus('Kết nối lại BLE thành công!', 'ok');
861
+ } catch (err) {
862
+ log(`Reconnect failed: ${err.message}`);
863
+ setStatus('Kết nối lại thất bại: ' + err.message, 'err');
864
+ devSub.textContent = 'Kết nối lại thất bại';
865
+ } finally {
866
+ btnReconnect.disabled = false;
867
+ }
868
+ }
869
+
870
+ btnConnect.addEventListener('click', doBleConnect);
871
+ btnDisconnect.addEventListener('click', doDisconnect);
872
+ btnReconnect.addEventListener('click', doReconnect);
873
 
874
  // ===============================
875
  // GENERATE MUSIC