Body ──► Pacemaker ──(read only)──► Heart ──► local HEART API (127.0.0.1)
patient the therapy watches └► HEART Hub (outbound only)
Part
In the file
In a real product
Body
The simulated patient: heart rate, movement, temperature, each on its own random timer, with episodes that come and go
The real patient
Pacemaker
The simulated therapy: senses, paces, rate response; a battery, lead, capture threshold and transmitter link that age, drift and fail
The implant and its certified software. Never part of HEART
Heart
Reads the pacemaker's values; RULES turn them into alerts with fault codes; escalate() tells a person; serves only what each caller may see
The code you add, on the home transmitter
monitor()
Hello, then reports to HEART Hub; follows the site's nextSec; stops for a wrong key; says hello again if forgotten
The same, unchanged
handler()
The HEART API: Card, Pulse, alerts, properties, rhythm; every change refused
The same, behind TLS on a network
Nothing in Heart can write to Pacemaker: there is no setter, no command and no capability. That is the design, not an omission.
7. What code to add to make a real product HEART-embedded
For a real pacing system, HEART runs on the home transmitter (or the patient's phone app), never in the implant: see Step 1 of the case study. Pick a starting point, then add four things.
Starting points
Your transmitter
Start from
Embedded Linux
This file's Heart, monitor() and handler(); or the Python template heart-templates/templates/python; or the Rust Core heartd (heart-core-rs) for HEART Full
A microcontroller with Wi-Fi
The ESP32 Full starter heart-templates/templates/esp32-full (its signal table, main-job check and Monitor reporting)
A phone app
The Python or JavaScript template, inside the app
The four things you add
Read the implant. Replace Body and Pacemaker with a reader for the telemetry your transmitter already receives. It must be read only: HEART gets values, never a way to send anything to the implant.
``python class Telemetry: """CHANGE: fill these from your transmitter's existing telemetry. Read only.""" def refresh(self): t = your_transmitter.latest() # your code self.paced, self.captured = t.pacing_needed, t.capture_confirmed self.impedance_now, self.threshold_now = t.lead_impedance_ohm, t.capture_threshold_v self.noise, self.link_down = t.noise_detected, not t.implant_in_range self.months = t.battery_months_left ``
Your rules and thresholds. Edit RULES and EXPLAIN with the limits from your implant's own specification. Keep the standard fault codes (Part 3), so every clinic system and monitor site reads them the same way.
Your escalation route. Change escalate() to flag the transmission to the clinic, and to light or sound the transmitter so the patient knows to call, with no internet needed.
Your privacy table. Go through properties() and give every value its level (Step 3 of the case study). Health data is private or internal; only status and fault codes are public. Keep patient names and numbers off the transmitter.
Then keep monitor() as it is, store the device ID, claim code and key in the transmitter's secure storage, and print the claim code and a QR code of https://<your monitor site>/register?id=<ID> on a label in the box. Serve the API over TLS (or keep it on loopback), run the HEART-Rating client and Part 9's tests, and claim HEART Basic, Autonomy Level A1.
531 lines of Python, standard library only. The live page above replays the same design in the site's own code; this file is the version you run.
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# Template: heart/embeddedstandard/pacemaker-simulator/v1.2611.2
"""pacemakerSimulator.py: a simulated pacemaker with a HEART inside, that reports to HEART Monitor.
A DEMONSTRATION ONLY. It is not a medical device, simulates no real patient, and must never be used for anything
medical. Its numbers are plausible, not clinical. See README.md beside this file, and the case study guides/Pacemaker.md.
What it does, in one process and with only the Python standard library:
* a simulated BODY: the patient's own heart rate (with day and night, exercise, slow-heart pauses and atrial
fibrillation episodes), movement and temperature, each changing on its own random timer;
* a simulated PACEMAKER (the therapy): it senses each beat and paces when the heart is too slow, with rate
response from movement, and has a battery, a lead, a capture threshold and a home transmitter link that age,
drift and sometimes fail;
* a HEART that only WATCHES the pacemaker. It never changes pacing: nothing in this file lets it. It notices
problems, raises alerts with standard fault codes, tells a person, serves the HEART API on this computer, and
reports public health (status and fault codes only) to a HEART Monitor site.
Run it:
python3 pacemakerSimulator.py # report to https://heartcortex.ai
python3 pacemakerSimulator.py --scenario all # also stage each fault, one after another, a few minutes apart
python3 pacemakerSimulator.py --no-monitor # local only: http://127.0.0.1:43278/heart/v1/pulse
python3 pacemakerSimulator.py --monitor-url http://127.0.0.1:8787 # a local monitor site (heart-monitor/test/serve.mjs)
At first start it prints a device ID and a claim code. Register them at https://heartcortex.ai/register, signed in
with your email, then watch the device at https://heartcortex.ai/device/<ID>. The ID, claim code and key are kept
in pacemaker-monitor.json (mode 600), so the same simulated device comes back after a restart.
Scenarios (--scenario, comma-separated): noise, link, capture, lead, battery, all. Each fault starts at a set time,
lasts a while, and is then "fixed at the clinic", so you can watch an alert open, escalate and resolve. battery
starts the battery near its replacement point and ages it fast.
Only public data ever leaves this computer: the Pulse status and the fault codes of open and recent alerts. Heart
rate, rhythm episodes, temperature and every device measurement stay local (internal or private).
"""
import argparse
import hashlib
import json
import math
import os
import random
import secrets
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SYSTEM_ID = "org.heartaispec.demo.pacemaker"
NAME = "Pacemaker Simulator (demo, not a medical device)"
VERSION = "1.0.0"
TEMPLATE = "heart/embeddedstandard/pacemaker-simulator/v1.2611.2"
PULSE_SEC = 30
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
# Programmed settings of the simulated pacemaker. Only a clinician's programmer may change them, never the HEART.
LOWER_RATE, UPPER_RATE = 60, 130 # beats per minute
OUTPUT_V = 2.5 # pacing output, volts
IMPEDANCE_OK = (200, 2000) # ohms: outside this, the lead is suspect
ERI_MONTHS, EOL_MONTHS = 3.0, 0.5 # elective replacement, and end of life
def log(msg):
print(time.strftime("%H:%M:%S ") + msg, file=sys.stderr, flush=True)
def utc(t=None):
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t))
# ------------------------------------------------------------------------------------------------ the simulation
class Timer:
"""A value that changes on its own random timer (in simulated seconds)."""
def __init__(self, rng, lo, hi):
self.rng, self.lo, self.hi, self.at = rng, lo, hi, 0.0
def due(self, t):
if t < self.at:
return False
self.at = t + self.rng.uniform(self.lo, self.hi)
return True
class Episode:
"""Something that happens now and then, for a while: next start after a random gap, lasting a random time."""
def __init__(self, rng, gap, length):
self.rng, self.gap, self.length = rng, gap, length
self.start = rng.uniform(*gap)
self.end = 0.0
def active(self, t):
if t >= self.start and self.end <= self.start:
self.end = self.start + self.rng.uniform(*self.length)
if self.end > self.start and t >= self.end:
self.start = t + self.rng.uniform(*self.gap)
return self.start <= t < self.end
class Body:
"""The simulated patient. Private data: it never leaves this computer."""
def __init__(self, rng):
self.rng = rng
self.activity, self.rate, self.temp = 0.1, 72.0, 36.9
self.t_activity, self.t_rate, self.t_temp = Timer(rng, 3, 15), Timer(rng, 1, 3), Timer(rng, 60, 300)
self.exercise = Episode(rng, (900, 3600), (300, 1200)) # a walk
self.pause = Episode(rng, (600, 2700), (20, 150)) # the patient's own heart slows: pacing takes over
self.af = Episode(rng, (3600, 4 * 3600), (120, 1200)) # atrial fibrillation, irregular and fast
self.fever = Episode(rng, (12 * 3600, 48 * 3600), (1800, 7200))
def step(self, t, wall):
hour = time.localtime(wall).tm_hour
night = hour >= 23 or hour < 6
if self.t_activity.due(t):
target = 0.6 if self.exercise.active(t) else (0.02 if night else 0.12)
self.activity = min(1.0, max(0.0, self.activity + (target - self.activity) * 0.4 + self.rng.gauss(0, 0.04)))
if self.t_rate.due(t):
base = (58 if night else 70) + 60 * self.activity
if self.pause.active(t):
base = self.rng.uniform(30, 40)
if self.af.active(t):
base = self.rng.uniform(100, 160)
self.rate += (base - self.rate) * 0.5 + self.rng.gauss(0, 1.5)
if self.t_temp.due(t):
target = 38.4 if self.fever.active(t) else 36.9 + 0.3 * self.activity
self.temp += (target - self.temp) * 0.3 + self.rng.gauss(0, 0.05)
class Pacemaker:
"""The simulated therapy. The HEART reads these values. Nothing in this file lets the HEART change them."""
def __init__(self, rng, scenarios):
self.rng, self.sc = rng, scenarios
self.months = 3.06 if "battery" in scenarios else rng.uniform(80, 110)
self.ageing = 4000.0 if "battery" in scenarios else 1.0 # the battery scenario ages it fast
self.impedance, self.threshold, self.r_wave = rng.uniform(480, 620), rng.uniform(0.6, 1.0), rng.uniform(7, 11)
self.t_imp, self.t_thr, self.t_rw = Timer(rng, 30, 120), Timer(rng, 60, 300), Timer(rng, 10, 60)
self.noise_ep = Episode(rng, (1800, 5400), (20, 60)) # electrical noise near a magnet or a motor
self.link_ep = Episode(rng, (2 * 3600, 6 * 3600), (120, 600)) # the bedside transmitter loses the implant
self.paced_fraction, self.rate, self.paced, self.captured = 0.0, 70.0, False, True
self.noise = self.link_down = False
self.events = [] # private rhythm log, newest last
def staged(self, name, t, start, length):
return name in self.sc and start <= t < start + length
def step(self, t, dt, body):
# Staged faults, for the demo. Each lasts a while and is then "fixed at the clinic".
lead_broken = self.staged("lead", t, 600, 240)
dislodged = self.staged("capture", t, 900, 180)
self.noise = self.noise_ep.active(t) or self.staged("noise", t, 60, 45)
self.link_down = self.link_ep.active(t) or self.staged("link", t, 180, 360)
if self.t_imp.due(t):
self.impedance = min(1400, max(300, self.impedance + self.rng.gauss(0, 8)))
if self.t_thr.due(t):
self.threshold = min(1.6, max(0.4, self.threshold + self.rng.gauss(0, 0.04)))
if self.t_rw.due(t):
self.r_wave = min(15, max(3, self.r_wave + self.rng.gauss(0, 0.4)))
impedance = self.rng.uniform(2600, 3400) if lead_broken else self.impedance
threshold = self.rng.uniform(3.0, 3.8) if dislodged else self.threshold
# One beat decision per step: pace when the heart is slower than the rate the patient's movement needs.
sensor_rate = LOWER_RATE + (UPPER_RATE - LOWER_RATE) * min(1.0, body.activity * 1.2)
own = body.rate
if dislodged:
own = min(own, 42) # the demo pairs the dislodged lead with a slow-heart pause, so pacing is needed
needed = own < sensor_rate and not self.noise # noise is taken for beats: pacing is held back
self.captured = (not needed) or (OUTPUT_V > threshold and IMPEDANCE_OK[0] <= impedance <= IMPEDANCE_OK[1])
self.paced = needed
self.rate = (sensor_rate if self.captured else own) if needed else own
k = min(1.0, dt / 600) # paced fraction over about 10 minutes
self.paced_fraction += ((1.0 if needed else 0.0) - self.paced_fraction) * k
drain = (1 + 0.5 * self.paced_fraction) * self.ageing # pacing uses the battery
self.months = max(0.0, self.months - dt * drain / (30.44 * 86400))
self.impedance_now, self.threshold_now = impedance, threshold
if body.af.active(t) and not (self.events and self.events[-1]["name"] == "af.started"):
self.events.append({"time": utc(), "name": "af.started", "rate": round(own)})
if not body.af.active(t) and self.events and self.events[-1]["name"] == "af.started":
self.events.append({"time": utc(), "name": "af.ended"})
self.events = self.events[-200:]
@property
def voltage(self):
# Lithium-iodine cells hold their voltage, then fall towards the end.
return round(2.45 + 0.35 * (1 - math.exp(-self.months / 6)), 3)
# ------------------------------------------------------------------------------------------------ the HEART
RULES = [
# code, severity, condition, summary
("system.main-function-lost", "critical", lambda p: p.paced and not p.captured,
"Pacing is not capturing the heart"),
("system.main-function-degraded", "warning", lambda p: p.captured and p.threshold_now > OUTPUT_V / 2,
"The pacing safety margin is low"),
("sensors.out-of-range", "error", lambda p: not IMPEDANCE_OK[0] <= p.impedance_now <= IMPEDANCE_OK[1],
"Lead impedance is out of range"),
("sensors.noisy", "warning", lambda p: p.noise, "Electrical noise is being sensed as heartbeats"),
("power.battery-low", "error", lambda p: p.months <= ERI_MONTHS, "The battery has reached its replacement point"),
("power.battery-failing", "critical", lambda p: p.months <= EOL_MONTHS, "The battery is at end of life"),
("connectivity.link-lost", "warning", lambda p: p.link_down, "The home transmitter cannot reach the implant"),
]
EXPLAIN = {
"system.main-function-lost": lambda p: f"Pacing at {OUTPUT_V} V, but the capture threshold is {p.threshold_now:.1f} V or the lead is not carrying the pulse. Contact the clinic now.",
"system.main-function-degraded": lambda p: f"Capture threshold {p.threshold_now:.2f} V against an output of {OUTPUT_V} V: less than a 2:1 safety margin.",
"sensors.out-of-range": lambda p: f"Lead impedance {p.impedance_now:.0f} ohms, outside {IMPEDANCE_OK[0]} to {IMPEDANCE_OK[1]} ohms: possible lead fracture or insulation break.",
"sensors.noisy": lambda p: "Noise may stop pacing when it is needed. Move away from strong magnets, motors or welding equipment.",
"power.battery-low": lambda p: f"About {p.months:.1f} months left ({p.voltage} V). Book a replacement.",
"power.battery-failing": lambda p: f"About {p.months * 30.44:.0f} days left ({p.voltage} V). Replacement is urgent.",
"connectivity.link-lost": lambda p: "Readings cannot be sent until the transmitter is back in range. The implant keeps working.",
}
class Heart:
"""Minimum Tending: notice, change status, raise, explain, escalate. No network or model is needed for any of it."""
def __init__(self, pacemaker, body):
self.pm, self.body = pacemaker, body
self.lock = threading.Lock()
self.sequence, self.status, self.alerts, self.since = 0, "unknown", [], {}
self.owner_key = secrets.token_urlsafe(18)
def tend(self, t):
with self.lock:
self.sequence += 1
for code, severity, cond, summary in RULES:
hit = cond(self.pm)
# A condition must hold for 5 simulated seconds before it is raised: one odd reading is not a fault.
prev = self.since.get(code)
self.since[code] = (t if prev is None else prev) if hit else None
held = hit and t - self.since[code] >= 5
open_ = next((a for a in self.alerts if a["code"] == code and a["status"] != "resolved"), None)
if held and open_ is None:
a = {"id": str(uuid.uuid4()), "code": code, "severity": severity, "status": "open",
"detectedAt": utc(), "summary": summary, "explanation": EXPLAIN[code](self.pm)}
self.alerts = (self.alerts + [a])[-100:]
self.escalate(a)
elif not hit and open_ is not None:
open_.update(status="resolved", resolvedAt=utc())
log(f"HEART resolved {code}: {open_['summary'].lower()}")
sev = {a["severity"] for a in self.alerts if a["status"] != "resolved"}
self.status = "failing" if "critical" in sev else "degraded" if sev else "healthy"
def escalate(self, a):
"""CHANGE for a product: the clinic's own route. Here: the console."""
a["status"] = "escalated"
log(f"HEART {a['severity']:8} {a['code']}: {a['summary']}. {a['explanation']}")
def envelope(self, kind, data, privacy="public"):
return {"specversion": "1.0", "id": str(uuid.uuid4()), "source": f"/heart/{SYSTEM_ID}/{INSTANCE}", "type": kind,
"time": utc(), "datacontenttype": "application/json", "heartversion": "1.0", "heartprivacy": privacy,
"data": data}
def pulse(self, owner=False):
with self.lock:
d = {"system": {"id": SYSTEM_ID, "name": NAME, "version": VERSION}, "status": self.status,
"sequence": self.sequence, "intervalSec": PULSE_SEC}
if owner:
d["reconnaissance"] = {"state": "pacing" if self.pm.paced else "sensing",
"lastEvent": self.pm.events[-1]["name"] if self.pm.events else "none"}
d["autonomy"] = {"current": 1}
return self.envelope("heart.pulse", d, "internal" if owner else "public")
def public_alerts(self, n=16):
with self.lock:
keep = ("id", "code", "severity", "status", "detectedAt", "resolvedAt")
return [self.envelope("heart.arrhythmia", {k: a[k] for k in keep if k in a}) for a in reversed(self.alerts[-n:])]
def properties(self, owner=False):
p, b = self.pm, self.body
items = [ # name, value, unit, privacy
("battery_status", "eol" if p.months <= EOL_MONTHS else "eri" if p.months <= ERI_MONTHS else "ok", None, "public"),
("battery_months", round(p.months, 2), "mo", "internal"),
("battery_voltage", p.voltage, "V", "internal"),
("lead_impedance", round(p.impedance_now), "Ohm", "internal"),
("capture_threshold", round(p.threshold_now, 2), "V", "internal"),
("r_wave", round(p.r_wave, 1), "mV", "internal"),
("paced_percent", round(100 * p.paced_fraction, 1), "%", "internal"),
("transmitter_link", "down" if p.link_down else "up", None, "internal"),
("heart_rate", round(p.rate), "/min", "private"),
("own_heart_rate", round(b.rate), "/min", "private"),
("activity", round(b.activity, 2), "1", "private"),
("temperature", round(b.temp, 1), "Cel", "private"),
]
out = []
for name, value, unit, privacy in items:
if privacy != "public" and not owner:
continue
d = {"name": f"{SYSTEM_ID}.{name}", "privacy": privacy, "value": value, "writable": False, "time": utc()}
if unit:
d["unit"] = unit
out.append(self.envelope("heart.property", d, privacy))
return out
# ------------------------------------------------------------------------------------------------ HEART Monitor
def identity(path, url, new=False):
"""Made once from a cryptographic random source, never from anything public."""
if not new and os.path.exists(path):
with open(path) as f:
return json.load(f)
ident = {"id": secrets.token_hex(8), "claimCode": "".join(secrets.choice(CROCKFORD) for _ in range(20)),
"key": secrets.token_urlsafe(32)}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(ident, f)
return ident
def show_claim(ident, url):
code = "-".join(ident["claimCode"][i:i + 5] for i in range(0, 20, 5))
print(f"""
HEART Monitor: register this simulated pacemaker
1. Sign in at {url}/login with your email (a 6-digit code is sent to you).
2. Open {url}/register?id={ident['id']}
3. Enter the claim code {code}
4. Watch it at {url}/device/{ident['id']} (private to you until you share it)
""", file=sys.stderr, flush=True)
def post(url, path, body, key=None):
req = urllib.request.Request(url.rstrip("/") + path, data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json", "User-Agent": "heart-pacemaker-simulator/1.0",
**({"Authorization": f"Bearer {key}"} if key else {})})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read() or b"{}")
except ValueError:
return e.code, {}
def monitor(heart, pm, url, path, stop):
"""Outbound only. Reports at most every 10 seconds, at once when something changes, and never while the
simulated home transmitter has lost the implant: then the site shows a Flatline after three missed intervals."""
host = url.split("://", 1)[-1].split("/")[0].split(":")[0]
if not url.startswith("https://") and host not in ("127.0.0.1", "localhost"):
log("HEART Monitor: refusing a site without https")
return
ident, phase, next_at, last, sent, backoff, claimed = identity(path, url), "hello", 0.0, 0.0, None, 0, None
show_claim(ident, url)
while not stop.is_set():
with heart.lock:
seen = (heart.status, tuple((a["code"], a["status"]) for a in heart.alerts[-16:]))
due = time.time() >= next_at or (phase == "report" and seen != sent and time.time() - last >= 10)
if not due or (phase == "report" and pm.link_down):
stop.wait(1)
continue
try:
if phase == "hello":
status, reply = post(url, f"/v1/devices/{ident['id']}/hello", {
"claimHash": hashlib.sha256(f"heart-claim:{ident['claimCode']}".encode()).hexdigest(),
"keyHash": hashlib.sha256(ident["key"].encode()).hexdigest(), "template": TEMPLATE})
if status in (200, 201):
phase, next_at = "report", 0
log(f"HEART Monitor: {url} knows this device")
elif status == 409:
ident = identity(path, url, new=True)
show_claim(ident, url)
else:
log(f"HEART Monitor: hello answered {status}; trying again in 10 minutes")
next_at = time.time() + 600
continue
last, sent = time.time(), seen
status, reply = post(url, f"/v1/devices/{ident['id']}/report",
{"pulse": heart.pulse(), "alerts": heart.public_alerts()}, ident["key"])
backoff = 0
if status in (200, 202) and reply.get("claimed") != claimed:
claimed = reply.get("claimed")
log("HEART Monitor: registered, reports are now stored" if claimed else
"HEART Monitor: not registered yet, nothing is stored (see the claim code above)")
if status in (200, 202, 429):
next_at = time.time() + int(reply.get("nextSec") or PULSE_SEC)
elif status == 404:
phase = "hello"
elif status == 401:
log("HEART Monitor: the site refused this device's key. Reporting has stopped.")
return
else:
log(f"HEART Monitor: report answered {status} {reply.get('detail', '')}; not retrying it unchanged")
next_at = time.time() + 600
except (OSError, ValueError) as e:
backoff = min(backoff * 2 or 30, 600)
log(f"HEART Monitor: offline ({e.__class__.__name__}); trying again in {backoff} s. Noticing goes on.")
next_at = time.time() + backoff
# ------------------------------------------------------------------------------------------------ the HEART API
def handler(heart):
class H(BaseHTTPRequestHandler):
def send(self, status, body, ctype="application/cloudevents+json"):
raw = json.dumps(body, indent=1).encode()
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(raw)))
self.send_header("Heart-Version", "1.0")
self.end_headers()
self.wfile.write(raw)
def problem(self, status, code, detail):
self.send(status, {"type": f"https://heartcortex.ai/problems/{code}", "title": code.replace("-", " "),
"status": status, "detail": detail, "code": code}, "application/problem+json")
def owner(self):
return secrets.compare_digest(self.headers.get("Authorization", ""), f"Bearer {heart.owner_key}")
def do_GET(self):
path, owner = self.path.split("?")[0].rstrip("/"), self.owner()
if path == "/.well-known/heart-card.json":
port = self.server.server_address[1]
return self.send(200, heart.envelope("heart.card", {
"system": {"id": SYSTEM_ID, "name": NAME, "version": VERSION}, "level": "basic",
"bindings": ["http-server"], "endpoints": {"api": f"http://127.0.0.1:{port}/heart/v1"},
"pulse": {"intervalSec": PULSE_SEC}, "modules": ["properties"],
"shares": {"pulse": "public", "arrhythmia": "public", "property": "private"},
"autonomy": {"level": 1}, "template": TEMPLATE}))
if path == "/heart/v1/pulse":
return self.send(200, heart.pulse(owner))
if path == "/heart/v1/arrhythmias":
if owner:
with heart.lock:
return self.send(200, {"items": [heart.envelope("heart.arrhythmia", dict(a), "internal")
for a in reversed(heart.alerts)]}, "application/json")
return self.send(200, {"items": heart.public_alerts(100)}, "application/json")
if path == "/heart/v1/properties":
return self.send(200, {"items": heart.properties(owner)}, "application/json")
if path == "/heart/v1/rhythm":
if not owner:
return self.problem(403, "insufficient-clearance", "Rhythm episodes are private: send the owner key")
return self.send(200, {"items": list(reversed(heart.pm.events))}, "application/json")
return self.problem(404, "not-found", "Try /heart/v1/pulse, /arrhythmias, /properties or /rhythm")
def refuse(self):
self.problem(403, "capability-not-allowed",
"This HEART only watches. Pacing is programmed by a clinician with a programmer, never through HEART.")
do_PUT = do_POST = do_PATCH = do_DELETE = lambda self: self.refuse()
def log_message(self, *a):
pass
return H
# ------------------------------------------------------------------------------------------------ main
INSTANCE = "sim"
def main():
global INSTANCE
ap = argparse.ArgumentParser(description="A simulated pacemaker with a HEART, reporting to HEART Monitor. Demo only.")
ap.add_argument("--monitor-url", default=os.environ.get("HEART_MONITOR_URL", "https://heartcortex.ai"))
ap.add_argument("--no-monitor", action="store_true", help="do not report to a monitor site")
ap.add_argument("--state-file", default="pacemaker-monitor.json", help="the device ID, claim code and key")
ap.add_argument("--port", type=int, default=43278, help="local HEART API port (0: none)")
ap.add_argument("--scenario", default="", help="noise,link,capture,lead,battery or all")
ap.add_argument("--speed", type=float, default=1.0, help="simulated seconds per real second (default 1)")
ap.add_argument("--seed", type=int, default=None, help="random seed, to repeat a run")
ap.add_argument("--duration", type=float, default=0, help="stop after this many real seconds (0: run until Ctrl-C)")
args = ap.parse_args()
scenarios = {"noise", "link", "capture", "lead", "battery"} if args.scenario == "all" else \
{s for s in args.scenario.split(",") if s}
unknown = scenarios - {"noise", "link", "capture", "lead", "battery"}
if unknown:
ap.error(f"unknown scenario: {', '.join(sorted(unknown))}")
rng = random.Random(args.seed)
body, pm = Body(rng), Pacemaker(rng, scenarios)
heart = Heart(pm, body)
if not args.no_monitor and os.path.exists(args.state_file):
with open(args.state_file) as f:
INSTANCE = json.load(f).get("id", INSTANCE)
elif not args.no_monitor:
INSTANCE = identity(args.state_file, args.monitor_url)["id"]
print(f"""
{NAME}
NOT A MEDICAL DEVICE. Simulated patient and implant. HEART only watches: it never changes pacing.
Scenarios: {', '.join(sorted(scenarios)) or 'none (random life only)'} · speed {args.speed}x
""", file=sys.stderr, flush=True)
stop = threading.Event()
if args.port:
try:
srv = ThreadingHTTPServer(("127.0.0.1", args.port), handler(heart))
except OSError:
srv = ThreadingHTTPServer(("127.0.0.1", 7685), handler(heart)) # the standard fallback port
threading.Thread(target=srv.serve_forever, daemon=True).start()
p = srv.server_address[1]
log(f"HEART API on http://127.0.0.1:{p}/heart/v1/pulse (owner key for private views: {heart.owner_key})")
if not args.no_monitor:
threading.Thread(target=monitor, args=(heart, pm, args.monitor_url, args.state_file, stop), daemon=True).start()
t, wall0, last_print = 0.0, time.time(), 0.0
try:
while not stop.is_set():
dt = args.speed
t += dt
body.step(t, wall0 + t)
pm.step(t, dt, body)
heart.tend(t)
if time.time() - last_print >= 15:
last_print = time.time()
log(f"rate {pm.rate:5.0f}/min ({('paced' if pm.captured else 'NO CAPTURE') if pm.paced else 'own'}) · paced {100 * pm.paced_fraction:4.1f}% · "
f"lead {pm.impedance_now:5.0f} ohm · threshold {pm.threshold_now:.2f} V · battery {pm.months:6.2f} mo · "
f"link {'down' if pm.link_down else 'up'} · HEART {heart.status}")
if args.duration and time.time() - wall0 >= args.duration:
break
time.sleep(1)
except KeyboardInterrupt:
pass
stop.set()
log("stopped. The monitor site will show a Flatline if this device stays silent for three Pulse intervals.")
if __name__ == "__main__":
main()