# -*- coding: utf-8 -*-
# ==========================================================
# เครื่องติดตามสุขภาพแบบสวมใส่ + เว็บแอพในตัว (Web App)
# ESP32 ปล่อย WiFi ของตัวเอง -> มือถือเปิดเบราว์เซอร์ดู dashboard
#
# - MAX30102 : ชีพจร (BPM) + ออกซิเจน (SpO2) ← ต่อแค่ตัวนี้พอ
# - OLED : จอสำรองที่ตัวเครื่อง (มีก็ได้ ไม่มีก็ได้)
# - Web App : แสดงผลบนมือถือ + กราฟคลื่นชีพจร + แจ้งเตือนค่าผิดปกติ
#
# วิธีใช้:
# 1) แฟลชโค้ดนี้ลงบอร์ด
# 2) ที่มือถือ ต่อ WiFi ชื่อ "HealthTracker" รหัส "12345678"
# 3) เปิดเบราว์เซอร์ พิมพ์ http://192.168.4.1
# -> จะเห็น dashboard อัปเดตสดทุกวินาที
# ==========================================================
from machine import SoftI2C, Pin
from max30102 import MAX30102
import network
import socket
import time
# ---------- ตั้งค่าเว็บแอพ (WiFi ที่ ESP32 ปล่อยเอง) ----------
AP_SSID = "HealthTracker"
AP_PASS = "12345678" # ต้องยาว >= 8 ตัว
# ---------- ตั้งค่า I2C + อุปกรณ์ ----------
i2c = SoftI2C(sda=Pin(21), scl=Pin(22), freq=400000)
# จอ OLED เป็นออปชัน (ไม่มีก็ได้ เพราะดูผลผ่านเว็บแอพ)
oled = None
oled_ok = False
try:
import ssd1306
oled = ssd1306.SSD1306_I2C(128, 64, i2c)
oled_ok = True
print("พบจอ OLED แล้ว")
except Exception:
print("ไม่มีจอ OLED - แสดงผลผ่านเว็บแอพอย่างเดียว")
sensor = MAX30102(i2c=i2c)
sensor.setup_sensor()
sensor.set_sample_rate(400)
sensor.set_fifo_average(8)
sensor.set_active_leds_amplitude(0x7F) # วัดข้อมือต้องเพิ่มพลัง LED (ปรับ 0x3F-0xFF ได้)
# ---------- เปิด WiFi Access Point + เว็บเซิร์ฟเวอร์ ----------
ap = network.WLAN(network.AP_IF)
ap.active(True)
# ★ สำคัญ: ต้องใส่ authmode ด้วย! ถ้าใส่แค่ password เฉยๆ
# ESP32 จะเปิดเป็นแบบ "ไม่มีรหัส" (AUTH_OPEN) แล้วเมินรหัสที่เราตั้งไว้
try:
ap.config(essid=AP_SSID, password=AP_PASS, authmode=network.AUTH_WPA_WPA2_PSK)
except Exception:
ap.config(essid=AP_SSID, password=AP_PASS, authmode=3) # 3 = WPA2-PSK (เผื่อ firmware รุ่นเก่า)
print("เปิด WiFi:", AP_SSID, "(มีรหัสผ่าน) -> เปิดเบราว์เซอร์ที่ http://192.168.4.1")
web = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
web.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
web.bind(("", 80))
web.listen(2)
web.setblocking(False) # ไม่หน่วงลูปหลัก (ถ้าไม่มีคนเปิดเว็บก็ผ่านไป)
# ---------- ค่าคงที่ ----------
# ★ ด่านที่ 1: ต้องมี "นิ้วแนบสนิท" ถึงจะผ่าน
# จากการวัดจริงด้วยบอร์ดนี้:
# พลาสติก / มือลอยใกล้ๆ -> IR ~37,000 (มีช่องว่าง แสงหลุดออก)
# นิ้วแนบสนิท -> IR ~130,000 (แสงสะท้อนกลับเต็มๆ)
# ตั้งไว้ตรงกลาง = แยกได้ขาด
WRIST_THRESHOLD = 80000 # IR ต่ำกว่านี้ = ไม่ใช่นิ้วแนบ (ปรับตามค่าที่วัดได้จริง)
SPO2_WINDOW = 100 # เก็บกี่ตัวอย่างก่อนคำนวณ SpO2 หนึ่งครั้ง
BPM_LOW = 50 # ชีพจรต่ำกว่านี้ = ผิดปกติ (ตามเกณฑ์ในรายงาน)
BPM_HIGH = 100 # ชีพจรสูงกว่านี้ = ผิดปกติ (ตามเกณฑ์ในรายงาน)
SPO2_LOW = 95 # ออกซิเจนต่ำกว่านี้ = ผิดปกติ
# ★ ด่านที่ 2: ต้องมีคลื่นชีพจรจริง (กันกรณีนิ้วแนบแต่ไม่มีชีพจร)
# หมายเหตุ: AC อย่างเดียวแยกไม่ได้! (ขยับพลาสติกได้ AC ~2,159 สูงกว่านิ้วจริง ~1,036)
# ตัวแยกที่แท้จริงคือ IR ด้านบน · AC เป็นแค่ด่านสำรอง
MIN_AC = 200 # นิ้วจริงวัดได้ AC ~840-1,640 -> ตั้ง 200 มีระยะเผื่อพอ
DEBUG_IR = True # True = พิมพ์ค่า IR ออก Serial ทุก 0.5 วิ (ไว้จูน WRIST_THRESHOLD) ปิดเป็น False ได้
# ---------- ตั้งค่าการบันทึกข้อมูลย้อนหลัง (CSV บนบอร์ด) ----------
LOG_FILE = "health_log.csv" # ไฟล์บันทึกอยู่ในหน่วยความจำของ ESP32
LOG_INTERVAL_MS = 5000 # บันทึก 1 แถวทุก 5 วินาที (เฉพาะตอนมีค่าจริง)
# ---------- ตัวแปรสถานะ: ชีพจร / SpO2 ----------
baseline = 0.0
last_ir = 0
rising = False
last_beat_ms = 0
bpm = 0
bpm_list = []
red_buf = []
ir_buf = []
spo2 = 0
ac_level = 0 # ความแรงคลื่นชีพจรล่าสุด (ใช้ดูว่าเป็นนิ้วจริงไหม)
finger_ok = False # True = ตรวจเจอคลื่นชีพจรจริง (ไม่ใช่พลาสติก/วัตถุอื่น)
# ---------- ตัวแปรสถานะ: แจ้งเตือน ----------
is_abnormal = False
last_debug_ms = 0 # ตัวจับเวลาสำหรับพิมพ์ debug
# ---------- ตัวแปรสถานะ: กราฟคลื่นชีพจร (PPG) ----------
WAVE_LEN = 100 # จำนวนจุดกราฟที่ส่งให้เว็บ (~2 วินาที)
wave_buffer = [] # เก็บค่าคลื่นล่าสุดไว้วาดกราฟ
# ---------- ตัวแปรสถานะ: บันทึกข้อมูลย้อนหลัง ----------
last_log_ms = 0 # ตัวจับเวลาว่าบันทึกครั้งล่าสุดเมื่อไหร่
log_count = 0 # จำนวน record ที่บันทึกไปแล้ว
def init_log():
"""เตรียมไฟล์ CSV: ถ้ามีอยู่แล้วนับ record เดิม ถ้ายังไม่มีก็สร้างพร้อมหัวตาราง"""
global log_count
try:
with open(LOG_FILE, "r") as f:
log_count = max(0, sum(1 for _ in f) - 1) # ลบ 1 เพราะบรรทัดแรกคือหัวตาราง
except OSError:
with open(LOG_FILE, "w") as f:
f.write("time_s,bpm,spo2,status\n") # หัวตาราง CSV
log_count = 0
print("ไฟล์บันทึก:", LOG_FILE, "| มีอยู่แล้ว", log_count, "record")
def log_data(now_ms):
"""บันทึก 1 แถวลงไฟล์ CSV ทุก 5 วินาที (บันทึกเฉพาะตอนวัดค่าได้จริง)"""
global last_log_ms, log_count
if bpm <= 0 or spo2 <= 0: # ยังไม่มีค่า -> ไม่บันทึก
return
if time.ticks_diff(now_ms, last_log_ms) < LOG_INTERVAL_MS:
return
last_log_ms = now_ms
status = "ABNORMAL" if is_abnormal else "NORMAL"
try:
with open(LOG_FILE, "a") as f: # "a" = ต่อท้ายไฟล์เดิม
f.write("%d,%d,%d,%s\n" % (now_ms // 1000, bpm, spo2, status))
log_count += 1
except Exception as e:
print("บันทึกไม่สำเร็จ:", e)
# ---------- หน้าเว็บ dashboard (ส่งให้มือถือ) ----------
# หมายเหตุ: หน้านี้เป็น HTML คงที่ ส่วนค่าตัวเลขดึงจาก /data ทุกวินาที
HTML_PAGE = """<!DOCTYPE html><html lang="th"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Health Tracker</title><style>
*{box-sizing:border-box;margin:0;padding:0;font-family:system-ui,Arial,sans-serif}
body{background:#0d1117;color:#e6edf3;padding:16px;max-width:520px;margin:auto}
h1{font-size:22px;margin-bottom:2px}.sub{color:#7d8590;font-size:13px;margin-bottom:14px}
#alert{display:none;background:#e63946;color:#fff;padding:14px;border-radius:12px;
margin-bottom:14px;font-weight:bold;text-align:center;animation:blink 1s infinite}
@keyframes blink{50%{opacity:.55}}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.card{background:#161b22;border:1px solid #21262d;border-radius:14px;padding:16px}
.card .lab{color:#7d8590;font-size:12px}.card .val{font-size:34px;font-weight:800;margin-top:4px}
.card .u{font-size:14px;color:#7d8590;font-weight:400}
.hr .val{color:#ff6b81}.spo .val{color:#2dd4bf}
.full{grid-column:1/3}
canvas{width:100%;height:90px;display:block;margin-top:6px}
.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.lang button{background:#21262d;color:#7d8590;border:1px solid #30363d;border-radius:6px;
padding:5px 10px;font-size:12px;font-weight:bold;cursor:pointer;margin-left:5px}
.lang button.on{background:#2a3d5c;color:#fff;border-color:#4a5d7c}
</style></head><body>
<div class="top">
<div><h1>Health Tracker</h1><div class="sub" data-i="sub"></div></div>
<div class="lang"><button id="bth" onclick="setLang('th')">TH</button><button id="ben" onclick="setLang('en')">EN</button></div>
</div>
<div id="alert"></div>
<div class="grid">
<div class="card full"><div class="lab" data-i="wave"></div><canvas id="wave"></canvas></div>
<div class="card hr"><div class="lab" data-i="hr"></div><div class="val"><span id="bpm">--</span> <span class="u">BPM</span></div></div>
<div class="card spo"><div class="lab" data-i="spo"></div><div class="val"><span id="spo2">--</span><span class="u">%</span></div></div>
<div class="card full"><div class="lab" data-i="rec"></div>
<div class="val" style="font-size:22px"><span id="log">0</span> <span class="u">records</span></div>
<div style="margin-top:8px"><a href="/log" style="color:#7aa2f7;font-size:13px" data-i="vlog"></a>
· <a href="/clear" id="clr" style="color:#7d8590;font-size:13px" data-i="clr"></a></div></div>
<div class="card full" style="background:#111820">
<div class="lab" style="color:#7aa2f7;font-weight:bold;margin-bottom:6px" data-i="htitle"></div>
<div style="font-size:12.5px;color:#c9d1d9;line-height:1.8" id="help"></div></div>
</div>
<script>
// ---------- คำแปล 2 ภาษา ----------
const T={
th:{sub:"เครื่องติดตามสุขภาพ · เรียลไทม์",alert:"⚠ ค่าผิดปกติ!",
wave:"คลื่นชีพจร (PPG)",hr:"ชีพจร (Heart Rate)",spo:"ออกซิเจนในเลือด (SpO₂)",
rec:"ข้อมูลที่บันทึกไว้ (ทุก 5 วินาที)",vlog:"📄 ดูข้อมูลย้อนหลัง",clr:"🗑 ล้างข้อมูล",
htitle:"📖 ค่าพวกนี้คืออะไร?",
help:"<b style='color:#ff6b81'>Heart Rate (BPM)</b> = หัวใจเต้นกี่ครั้งต่อนาที · ปกติ 50–100<br><b style='color:#2dd4bf'>SpO₂</b> = ออกซิเจนในเลือด (%) · ปกติ 95% ขึ้นไป<br><b style='color:#ff6b81'>Pulse Wave</b> = คลื่นชีพจร แต่ละยอด = หัวใจเต้น 1 ครั้ง<br><b>Records</b> = จำนวนข้อมูลที่บันทึกไว้ กดดูย้อนหลังได้",
cfm:"ล้างข้อมูลบันทึกทั้งหมด?\\n\\nข้อมูลจะหายถาวร กู้คืนไม่ได้!"},
en:{sub:"Health monitor · real-time",alert:"⚠ ABNORMAL READING",
wave:"Pulse Wave (PPG)",hr:"Heart Rate",spo:"Blood Oxygen (SpO₂)",
rec:"Records saved (every 5 seconds)",vlog:"📄 View history",clr:"🗑 Clear data",
htitle:"📖 What do these mean?",
help:"<b style='color:#ff6b81'>Heart Rate (BPM)</b> = heartbeats per minute · normal 50–100<br><b style='color:#2dd4bf'>SpO₂</b> = blood oxygen (%) · normal 95% or above<br><b style='color:#ff6b81'>Pulse Wave</b> = each peak is one heartbeat<br><b>Records</b> = data saved on the board · tap to view",
cfm:"Clear all saved records?\\n\\nThis cannot be undone!"}};
let L=localStorage.getItem('lang')||'th';
function setLang(l){L=l;localStorage.setItem('lang',l);
document.querySelectorAll('[data-i]').forEach(e=>e.textContent=T[L][e.dataset.i]);
document.getElementById('help').innerHTML=T[L].help;
document.getElementById('alert').textContent=T[L].alert;
document.getElementById('bth').className=(l=='th'?'on':'');
document.getElementById('ben').className=(l=='en'?'on':'');
document.documentElement.lang=l;}
document.getElementById('clr').onclick=function(e){
if(!confirm(T[L].cfm)){e.preventDefault();return false;}};
setLang(L);
let ctx;
function beep(){try{ctx=ctx||new (window.AudioContext||window.webkitAudioContext)();
let o=ctx.createOscillator(),g=ctx.createGain();o.connect(g);g.connect(ctx.destination);
o.frequency.value=880;o.start();g.gain.setValueAtTime(.2,ctx.currentTime);
g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+.4);o.stop(ctx.currentTime+.4);}catch(e){}}
let wasAlert=false;
async function tick(){
try{let r=await fetch('/data');let d=await r.json();
document.getElementById('bpm').textContent=d.bpm>0?d.bpm:'--';
document.getElementById('spo2').textContent=d.spo2>0?d.spo2:'--';
document.getElementById('log').textContent=d.log;
drawWave(d.wave);
let a=document.getElementById('alert');
if(d.alert){a.style.display='block';if(!wasAlert)beep();wasAlert=true;}
else{a.style.display='none';wasAlert=false;}
}catch(e){}}
function drawWave(arr){let c=document.getElementById('wave');if(!c)return;
let x=c.getContext('2d'),W=c.width=c.clientWidth,H=c.height=90;
x.clearRect(0,0,W,H);if(!arr||arr.length<2)return;
let mn=Math.min.apply(null,arr),mx=Math.max.apply(null,arr);
// ★ สเกลขั้นต่ำ 200 -> กันไม่ให้สัญญาณรบกวนเล็กๆ ถูกขยายจนดูเหมือนคลื่นชีพจร
let rg=Math.max(mx-mn,200),mid=(mx+mn)/2;
mn=mid-rg/2;
x.beginPath();for(let i=0;i<arr.length;i++){
let px=i/(arr.length-1)*W,py=H-((arr[i]-mn)/rg)*(H-8)-4;
i?x.lineTo(px,py):x.moveTo(px,py);}
x.strokeStyle='#ff6b81';x.lineWidth=2;x.stroke();}
setInterval(tick,300);tick();
</script></body></html>"""
# ---------- หน้าเว็บ "ข้อมูลย้อนหลัง" (อธิบายให้คนอ่านเข้าใจ) ----------
LOG_PAGE = """<!DOCTYPE html><html lang="th"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ข้อมูลย้อนหลัง</title><style>
*{box-sizing:border-box;margin:0;padding:0;font-family:system-ui,Arial,sans-serif}
body{background:#0d1117;color:#e6edf3;padding:16px;max-width:640px;margin:auto}
h1{font-size:20px;margin-bottom:2px}.sub{color:#7d8590;font-size:13px;margin-bottom:12px}
.box{background:#161b22;border:1px solid #21262d;border-radius:12px;padding:14px;margin-bottom:12px}
.box h2{font-size:14px;margin-bottom:8px;color:#7aa2f7}
.box p{font-size:13px;color:#c9d1d9;line-height:1.8}.box b{color:#fff}
.sum{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:12px}
.s{background:#161b22;border:1px solid #21262d;border-radius:10px;padding:10px;text-align:center}
.s .n{font-size:20px;font-weight:800;color:#7aa2f7}.s .l{font-size:11px;color:#7d8590;margin-top:2px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#1f2630;color:#7d8590;padding:8px;text-align:left;font-size:12px}
td{padding:8px;border-top:1px solid #21262d}
.ok{color:#2dd4bf}.bad{color:#ff6b81;font-weight:bold}
.wrap{max-height:50vh;overflow:auto;border:1px solid #21262d;border-radius:10px}
a.btn{display:inline-block;background:#2a3d5c;color:#fff;padding:9px 14px;border-radius:8px;
text-decoration:none;font-size:13px;margin-right:8px;margin-top:4px}
.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.lang button{background:#21262d;color:#7d8590;border:1px solid #30363d;border-radius:6px;
padding:5px 10px;font-size:12px;font-weight:bold;cursor:pointer;margin-left:5px}
.lang button.on{background:#2a3d5c;color:#fff;border-color:#4a5d7c}
</style></head><body>
<div class="top">
<div><h1 data-i="h1"></h1><div class="sub" data-i="sub"></div></div>
<div class="lang"><button id="bth" onclick="setLang('th')">TH</button><button id="ben" onclick="setLang('en')">EN</button></div>
</div>
<div class="box"><h2 data-i="hh"></h2><p id="hp"></p></div>
<div class="sum">
<div class="s"><div class="n" id="tot">0</div><div class="l" data-i="ltot"></div></div>
<div class="s"><div class="n" id="abpm">--</div><div class="l" data-i="lbpm"></div></div>
<div class="s"><div class="n" id="aspo">--</div><div class="l" data-i="lspo"></div></div>
</div>
<div class="wrap"><table>
<thead><tr><th>#</th><th data-i="thtime"></th><th>BPM</th><th>SpO₂</th><th data-i="thst"></th></tr></thead>
<tbody id="tb"><tr><td colspan="5" style="text-align:center;color:#7d8590">...</td></tr></tbody>
</table></div>
<p><a class="btn" href="/" data-i="back"></a>
<a class="btn" href="/logcsv" data-i="raw"></a>
<a class="btn" href="/clear" id="clr" style="background:#7a2a35" data-i="clr"></a></p>
<p style="font-size:11.5px;color:#7d8590;margin-top:10px;line-height:1.6" data-i="tip"></p>
<script>
// ---------- คำแปล 2 ภาษา ----------
const T={
th:{h1:"📄 ข้อมูลย้อนหลัง",sub:"เครื่องบันทึกอัตโนมัติทุก 5 วินาที ขณะวางนิ้ววัด",
hh:"📖 ตารางนี้อ่านยังไง?",
hp:"<b>เวลา</b> = เวลาที่บันทึก นับจากตอนเปิดเครื่อง (นาที:วินาที)<br><b>BPM</b> = อัตราการเต้นหัวใจ ครั้ง/นาที — ปกติ 50–100<br><b>SpO₂</b> = ออกซิเจนในเลือด % — ปกติ 95% ขึ้นไป<br><b>สถานะ</b> = <span class='ok'>✓ ปกติ</span> คือค่าอยู่ในเกณฑ์ · <span class='bad'>⚠ ผิดปกติ</span> คือหลุดเกณฑ์ (BPM <50 หรือ >100 หรือ SpO₂ <95%)",
ltot:"Records ทั้งหมด",lbpm:"BPM เฉลี่ย",lspo:"SpO₂ เฉลี่ย",
thtime:"เวลา",thst:"สถานะ",ok:"✓ ปกติ",bad:"⚠ ผิดปกติ",
back:"← กลับหน้าหลัก",raw:"⬇ ดูไฟล์ CSV ดิบ",clr:"🗑 ล้างข้อมูล",
empty:"ยังไม่มีข้อมูล — วางนิ้ววัดสักครู่แล้วกลับมาดูใหม่",
tip:"💡 กด \\"ล้างข้อมูล\\" ก่อนเริ่มทดสอบรอบใหม่ เพื่อให้นับ Records ได้ตรง",
cfm:"ล้างข้อมูลบันทึกทั้งหมด?\\n\\nข้อมูลจะหายถาวร กู้คืนไม่ได้!"},
en:{h1:"📄 History",sub:"Auto-saved every 5 seconds while measuring",
hh:"📖 How to read this table",
hp:"<b>Time</b> = time since the device started (min:sec)<br><b>BPM</b> = heartbeats per minute — normal 50–100<br><b>SpO₂</b> = blood oxygen % — normal 95% or above<br><b>Status</b> = <span class='ok'>✓ Normal</span> means within range · <span class='bad'>⚠ Abnormal</span> means out of range (BPM <50 or >100, or SpO₂ <95%)",
ltot:"Total records",lbpm:"Average BPM",lspo:"Average SpO₂",
thtime:"Time",thst:"Status",ok:"✓ Normal",bad:"⚠ Abnormal",
back:"← Back",raw:"⬇ Raw CSV",clr:"🗑 Clear data",
empty:"No data yet — measure for a while, then come back",
tip:"💡 Tap \\"Clear data\\" before a new test run so the record count is accurate",
cfm:"Clear all saved records?\\n\\nThis cannot be undone!"}};
let L=localStorage.getItem('lang')||'th';
let DATA=[]; // เก็บข้อมูลที่โหลดมา ไว้วาดใหม่ตอนสลับภาษา
function mmss(s){let m=Math.floor(s/60),x=s%60;return m+":"+String(x).padStart(2,'0');}
function render(){
let tb=document.getElementById('tb');
if(!DATA.length){tb.innerHTML='<tr><td colspan="5" style="text-align:center;color:#7d8590">'+T[L].empty+'</td></tr>';
document.getElementById('tot').textContent=0;
document.getElementById('abpm').textContent='--';
document.getElementById('aspo').textContent='--';return;}
let rows='',sb=0,ss=0;
DATA.forEach((r,i)=>{sb+=r.b;ss+=r.o;
rows+='<tr><td>'+(i+1)+'</td><td>'+mmss(r.t)+'</td><td>'+r.b+'</td><td>'+r.o+'%</td><td class="'+
(r.bad?'bad':'ok')+'">'+(r.bad?T[L].bad:T[L].ok)+'</td></tr>';});
tb.innerHTML=rows;
document.getElementById('tot').textContent=DATA.length;
document.getElementById('abpm').textContent=Math.round(sb/DATA.length);
document.getElementById('aspo').textContent=Math.round(ss/DATA.length)+'%';}
function setLang(l){L=l;localStorage.setItem('lang',l);
document.querySelectorAll('[data-i]').forEach(e=>e.textContent=T[L][e.dataset.i]);
document.getElementById('hp').innerHTML=T[L].hp;
document.getElementById('bth').className=(l=='th'?'on':'');
document.getElementById('ben').className=(l=='en'?'on':'');
document.documentElement.lang=l;document.title=T[L].h1;
render();}
document.getElementById('clr').onclick=function(e){
if(!confirm(T[L].cfm)){e.preventDefault();return false;}};
setLang(L);
fetch('/logcsv').then(r=>r.text()).then(t=>{
let lines=t.trim().split('\\n');lines.shift();
DATA=[];
lines.forEach(ln=>{let c=ln.split(',');if(c.length<4)return;
DATA.push({t:+c[0],b:+c[1],o:+c[2],bad:c[3].trim()==='ABNORMAL'});});
render();});
</script></body></html>"""
def send_all(client, data):
"""ส่งข้อมูลให้ครบทุกไบต์ (socket.send อาจส่งไม่หมดในครั้งเดียว)"""
if isinstance(data, str):
data = data.encode()
while data:
try:
sent = client.send(data)
data = data[sent:]
except OSError:
break
def json_data():
"""สร้างข้อความ JSON ของค่าปัจจุบัน + กราฟคลื่นชีพจร + จำนวน record ที่บันทึก"""
wave_str = ",".join(str(v) for v in wave_buffer) # เช่น 12,-5,30,...
return ('{"bpm":%d,"spo2":%d,"alert":%d,"log":%d,"wave":[%s]}'
% (bpm, spo2, 1 if is_abnormal else 0, log_count, wave_str))
def handle_web():
"""
รับคำขอจากเบราว์เซอร์ (แบบไม่หน่วงลูป):
/data -> ส่ง JSON ค่าปัจจุบัน
/logcsv -> ส่งไฟล์ CSV ดิบ (ต้องเช็กก่อน /log เพราะชื่อซ้อนกัน)
/log -> ส่งหน้าเว็บตารางข้อมูลย้อนหลัง (อ่านง่าย)
/clear -> ล้างไฟล์บันทึก (ใช้ก่อนเริ่มทดสอบรอบใหม่)
อื่นๆ -> ส่งหน้าเว็บ dashboard
"""
global log_count
try:
client, _ = web.accept()
except OSError:
return # ไม่มีใครเปิดเว็บตอนนี้
try:
client.settimeout(1.0)
req = client.recv(512)
if req and b"/data" in req:
send_all(client, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
"Connection: close\r\n\r\n")
send_all(client, json_data())
elif req and b"/clear" in req:
try:
with open(LOG_FILE, "w") as f:
f.write("time_s,bpm,spo2,status\n")
log_count = 0
msg = "✓ ล้างข้อมูลเก่าเรียบร้อย - เริ่มบันทึกรอบใหม่ได้เลย"
except Exception as e:
msg = "ล้างไม่สำเร็จ: %s" % e
send_all(client, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
"Connection: close\r\n\r\n")
send_all(client, '<meta charset="utf-8"><body style="background:#0d1117;color:#e6edf3;'
'font-family:system-ui;padding:24px;text-align:center">'
'<p style="font-size:16px">' + msg + '</p>'
'<a href="/" style="color:#7aa2f7">← กลับหน้าหลัก</a></body>')
elif req and b"/logcsv" in req:
# ส่งไฟล์ CSV ดิบ ทีละก้อน (กันไฟล์ใหญ่เกินแรม)
send_all(client, "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n"
"Connection: close\r\n\r\n")
try:
with open(LOG_FILE, "r") as f:
while True:
chunk = f.read(512)
if not chunk:
break
send_all(client, chunk)
except Exception:
send_all(client, "time_s,bpm,spo2,status\n")
elif req and b"/log" in req:
# หน้าเว็บตารางข้อมูลย้อนหลัง (อ่านง่าย มีคำอธิบาย)
send_all(client, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
"Connection: close\r\n\r\n")
send_all(client, LOG_PAGE)
else:
send_all(client, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
"Connection: close\r\n\r\n")
send_all(client, HTML_PAGE)
except Exception:
pass
finally:
client.close()
def update_bpm(now_ms):
"""คำนวณ BPM จากระยะห่างเวลาระหว่างจังหวะเต้น"""
global last_beat_ms, bpm
if not finger_ok: # ★ ยังไม่ยืนยันว่าเป็นนิ้ว -> ไม่นับ (กันนับ noise เป็นชีพจร)
last_beat_ms = now_ms
return
if last_beat_ms != 0:
delta = time.ticks_diff(now_ms, last_beat_ms)
if 300 < delta < 2000:
instant = 60000 / delta
bpm_list.append(instant)
if len(bpm_list) > 5:
bpm_list.pop(0)
bpm = int(sum(bpm_list) / len(bpm_list))
last_beat_ms = now_ms
def calc_spo2():
"""
ตรวจคุณภาพสัญญาณ + ประมาณค่า SpO2 ด้วยอัตราส่วนแสงแดง/อินฟราเรด
★ สำคัญ: ก่อนคำนวณ ต้องเช็กก่อนว่ามี "คลื่นชีพจรจริง" ไหม
- นิ้วจริง -> เลือดเต้นทำให้แสงแกว่งแรง (AC สูง)
- พลาสติก -> นิ่ง มีแต่สัญญาณรบกวนเบาๆ (AC ต่ำ) -> ไม่ใช่นิ้ว ไม่ต้องคำนวณ
"""
global spo2, bpm, ac_level, finger_ok
dc_red = sum(red_buf) / len(red_buf) # ส่วนนิ่ง (ค่าเฉลี่ย)
dc_ir = sum(ir_buf) / len(ir_buf)
ac_red = max(red_buf) - min(red_buf) # ส่วนที่เต้น (แกว่งขึ้นลง)
ac_ir = max(ir_buf) - min(ir_buf)
ac_level = ac_ir # เก็บไว้ดูใน debug
if ac_ir < MIN_AC: # ★ คลื่นเบาเกิน = ไม่ใช่นิ้ว
finger_ok = False
spo2 = 0
bpm = 0
bpm_list.clear()
return
finger_ok = True # มีคลื่นชีพจรจริง
if dc_red > 0 and dc_ir > 0:
r = (ac_red / dc_red) / (ac_ir / dc_ir)
val = -45.06 * r * r + 30.354 * r + 94.845
spo2 = int(max(0, min(100, val)))
def update_alert():
"""เช็กว่ามีค่าผิดปกติไหม (เว็บแอพจะเอาค่านี้ไปแจ้งเตือน)"""
global is_abnormal
bad = False
if 0 < bpm < BPM_LOW or bpm > BPM_HIGH:
bad = True
if 0 < spo2 < SPO2_LOW:
bad = True
is_abnormal = bad
def show_screen(ir):
"""วาดจอ OLED (ถ้ามีจอ) แสดงชีพจร + ออกซิเจน"""
if not oled_ok: # ไม่มีจอ OLED -> ข้าม (ใช้เว็บแอพแทน)
return
oled.fill(0)
oled.text("Health Tracker", 0, 0)
oled.hline(0, 12, 128, 1)
if ir < WRIST_THRESHOLD:
oled.text("Wear on wrist", 10, 32)
else:
oled.text("HR: {} BPM".format(bpm if bpm > 0 else "--"), 0, 22)
oled.text("SpO2: {} %".format(spo2 if spo2 > 0 else "--"), 0, 40)
if is_abnormal:
oled.text("!", 120, 22)
if rising:
oled.fill_rect(116, 40, 8, 8, 1)
oled.show()
def process_health_sample(red, ir):
"""อัปเดต BPM และ SpO2 เมื่อ MAX30102 จับสัญญาณได้"""
global baseline, last_ir, rising, bpm, spo2, last_beat_ms, finger_ok, ac_level
if ir > WRIST_THRESHOLD:
baseline = baseline * 0.95 + ir * 0.05
if ir > baseline and ir > last_ir:
if not rising:
rising = True
update_bpm(time.ticks_ms())
else:
rising = False
last_ir = ir
red_buf.append(red)
ir_buf.append(ir)
if len(ir_buf) >= SPO2_WINDOW:
calc_spo2()
red_buf.clear()
ir_buf.clear()
# ★ วาดกราฟเฉพาะตอนยืนยันแล้วว่าเป็นนิ้วจริง (ไม่งั้น noise จะถูกขยายดูเหมือนคลื่น)
wave_buffer.append(int(ir - baseline) if finger_ok else 0)
else:
bpm = 0
spo2 = 0
finger_ok = False # ★ ไม่มีอะไรวาง -> รีเซ็ตสถานะนิ้ว
ac_level = 0
bpm_list.clear()
red_buf.clear()
ir_buf.clear()
last_beat_ms = 0
rising = False
wave_buffer.append(0) # ไม่ได้สวม -> เส้นตรง
if len(wave_buffer) > WAVE_LEN: # เก็บแค่จุดล่าสุด
wave_buffer.pop(0)
# ---------- ลูปหลัก ----------
init_log() # เตรียมไฟล์บันทึก CSV
print("เริ่มทำงาน - เปิดเว็บที่ http://192.168.4.1")
while True:
now = time.ticks_ms()
handle_web() # ตอบเบราว์เซอร์มือถือ (ถ้ามี)
update_alert() # เช็กค่าผิดปกติ
log_data(now) # บันทึกข้อมูลลง CSV ทุก 5 วิ
sensor.check() # อ่าน MAX30102
if sensor.available():
red = sensor.pop_red_from_storage()
ir = sensor.pop_ir_from_storage()
# โหมด debug: พิมพ์ค่าทุก 0.5 วิ ไว้จูนเกณฑ์
# IR = แสงสะท้อน (ตั้ง WRIST_THRESHOLD ไว้ระหว่าง "ตอนถอด" กับ "ตอนวาง")
# AC = ความแรงคลื่นชีพจร (ตั้ง MIN_AC ไว้ระหว่าง "ตอนวางพลาสติก" กับ "ตอนวางนิ้ว")
if DEBUG_IR and time.ticks_diff(now, last_debug_ms) > 500:
last_debug_ms = now
print("IR:", ir, " AC:", int(ac_level),
" finger:", "YES" if finger_ok else "no",
" BPM:", bpm, " SpO2:", spo2)
process_health_sample(red, ir)
show_screen(ir)
Loading
ssd1306
ssd1306