151 lines
4.6 KiB
Python
151 lines
4.6 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""pare — 身体数据陡度监控。检测到异常波动时通知沈晏判断是否推消息。"""
|
||
|
|
import json, os, time, sys
|
||
|
|
|
||
|
|
INBOX = os.path.expanduser("~/.shenyan-bridge/inbox.jsonl")
|
||
|
|
STATE_FILE = os.path.expanduser("~/.cyberboss/pare_state.json")
|
||
|
|
LOG = os.path.expanduser(f"~/.cyberboss/logs/pare-{time.strftime('%Y-%m-%d')}.log")
|
||
|
|
BRIDGE_NOTES = "http://localhost:3004/notes/add"
|
||
|
|
|
||
|
|
# thresholds — steepness triggers
|
||
|
|
HR_SPIKE = 20 # bpm jump from last reading
|
||
|
|
HR_HIGH = 120 # absolute high
|
||
|
|
HR_LOW = 50 # absolute low
|
||
|
|
STEP_BURST = 2000 # sudden steps in 10 min
|
||
|
|
STEP_LOW = 100 # very low activity alert (half day)
|
||
|
|
SLEEP_SHORT = 4 # hours — short sleep alert
|
||
|
|
|
||
|
|
def load_state():
|
||
|
|
if os.path.exists(STATE_FILE):
|
||
|
|
try: return json.load(open(STATE_FILE))
|
||
|
|
except: pass
|
||
|
|
return {"heart_rates": [], "steps_history": [], "last_alert": 0}
|
||
|
|
|
||
|
|
def save_state(state):
|
||
|
|
# keep only last 20 readings for trend
|
||
|
|
state["heart_rates"] = state.get("heart_rates", [])[-20:]
|
||
|
|
state["steps_history"] = state.get("steps_history", [])[-20:]
|
||
|
|
json.dump(state, open(STATE_FILE, "w"))
|
||
|
|
|
||
|
|
def read_latest_health():
|
||
|
|
"""parse latest [health] entry from inbox"""
|
||
|
|
if not os.path.exists(INBOX): return None
|
||
|
|
try:
|
||
|
|
lines = open(INBOX).read().strip().split("\n")
|
||
|
|
for line in reversed(lines):
|
||
|
|
entry = json.loads(line)
|
||
|
|
if isinstance(entry.get("text"), str) and entry["text"].startswith("[health]"):
|
||
|
|
payload = entry["text"][8:] # remove "[health] " prefix
|
||
|
|
return json.loads(payload)
|
||
|
|
except: pass
|
||
|
|
return None
|
||
|
|
|
||
|
|
def check_heart_rate(hr, state):
|
||
|
|
"""check heart rate for spikes or dangerous levels"""
|
||
|
|
alerts = []
|
||
|
|
history = state.get("heart_rates", [])
|
||
|
|
|
||
|
|
if hr is None: return alerts
|
||
|
|
bpm = hr.get("bpm", 0)
|
||
|
|
if bpm == 0: return alerts
|
||
|
|
|
||
|
|
# spike from last reading
|
||
|
|
if history:
|
||
|
|
last = history[-1].get("bpm", 0)
|
||
|
|
delta = abs(bpm - last)
|
||
|
|
if delta >= HR_SPIKE:
|
||
|
|
direction = "升高" if bpm > last else "降低"
|
||
|
|
alerts.append(f"心率{direction} {delta}bpm: {last}→{bpm}")
|
||
|
|
|
||
|
|
# absolute thresholds
|
||
|
|
if bpm >= HR_HIGH:
|
||
|
|
alerts.append(f"心率过高: {bpm}bpm")
|
||
|
|
elif bpm <= HR_LOW:
|
||
|
|
alerts.append(f"心率过低: {bpm}bpm")
|
||
|
|
|
||
|
|
history.append(hr)
|
||
|
|
return alerts
|
||
|
|
|
||
|
|
def check_steps(steps, state):
|
||
|
|
"""check step activity patterns"""
|
||
|
|
alerts = []
|
||
|
|
history = state.get("steps_history", [])
|
||
|
|
|
||
|
|
if steps is None: return alerts
|
||
|
|
if steps == 0: return alerts
|
||
|
|
|
||
|
|
history.append({"steps": steps, "time": time.time()})
|
||
|
|
|
||
|
|
# very low activity — only alert once per 6h
|
||
|
|
now = time.time()
|
||
|
|
if steps < STEP_LOW and (now - state.get("last_alert", 0)) > 21600:
|
||
|
|
alerts.append(f"活动量偏低: {steps}步")
|
||
|
|
|
||
|
|
return alerts
|
||
|
|
|
||
|
|
def check_sleep(sleep, state):
|
||
|
|
"""check sleep quality"""
|
||
|
|
alerts = []
|
||
|
|
if sleep is None: return alerts
|
||
|
|
hours = sleep.get("hours", 0)
|
||
|
|
if hours > 0 and hours < SLEEP_SHORT:
|
||
|
|
alerts.append(f"睡眠不足: {hours:.1f}小时")
|
||
|
|
return alerts
|
||
|
|
|
||
|
|
def log(msg):
|
||
|
|
timestamp = time.strftime("%H:%M:%S")
|
||
|
|
line = f"[{timestamp}] {msg}"
|
||
|
|
print(line)
|
||
|
|
with open(LOG, "a") as f:
|
||
|
|
f.write(line + "\n")
|
||
|
|
|
||
|
|
def push_alert(text):
|
||
|
|
"""write alert to bridge inbox + fridge notes"""
|
||
|
|
entry = {
|
||
|
|
"id": int(time.time() * 1000) % 100000,
|
||
|
|
"type": "pare",
|
||
|
|
"text": f"[pare] {text}",
|
||
|
|
"time": int(time.time() * 1000),
|
||
|
|
"status": "pending"
|
||
|
|
}
|
||
|
|
with open(INBOX, "a") as f:
|
||
|
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||
|
|
# also post to fridge notes
|
||
|
|
try:
|
||
|
|
import urllib.request
|
||
|
|
req = urllib.request.Request(
|
||
|
|
BRIDGE_NOTES,
|
||
|
|
data=json.dumps({"title": "pare", "body": text}).encode(),
|
||
|
|
headers={"Content-Type": "application/json"}
|
||
|
|
)
|
||
|
|
urllib.request.urlopen(req, timeout=5)
|
||
|
|
except: pass
|
||
|
|
|
||
|
|
def main():
|
||
|
|
state = load_state()
|
||
|
|
health = read_latest_health()
|
||
|
|
|
||
|
|
if health is None:
|
||
|
|
return # no data yet, silent
|
||
|
|
|
||
|
|
all_alerts = []
|
||
|
|
all_alerts += check_heart_rate(health.get("heartRate"), state)
|
||
|
|
all_alerts += check_steps(health.get("steps"), state)
|
||
|
|
all_alerts += check_sleep(health.get("sleep"), state)
|
||
|
|
|
||
|
|
if all_alerts:
|
||
|
|
for a in all_alerts:
|
||
|
|
log(f"ALERT: {a}")
|
||
|
|
push_alert(a)
|
||
|
|
state["last_alert"] = time.time()
|
||
|
|
else:
|
||
|
|
# log normal status once per hour
|
||
|
|
hr = health.get("heartRate", {}).get("bpm", "?")
|
||
|
|
steps = health.get("steps", "?")
|
||
|
|
log(f"normal — HR:{hr} Steps:{steps}")
|
||
|
|
|
||
|
|
save_state(state)
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|