Old script used deprecated Health.read() with Health.Types.* and .quantity — every read silently failed in try/catch, so no samples were ever collected. Rewrote to use Health.queryQuantitySamples() with string type identifiers and .quantityValue(unit) per current Scripting app docs. Added proper units (kg, mmHg, count/min, count, percent) for each metric type. Also adds /scriptable/health-sync.js endpoint to serve the script directly.
159 lines
No EOL
4 KiB
JavaScript
159 lines
No EOL
4 KiB
JavaScript
// Health Sync — reads Apple Health and POSTs to nutrition-mcp server
|
|
// Run via Scriptable/Scripting automation at 9:30am daily
|
|
// Server: http://chronos:9432/ingest
|
|
//
|
|
// Uses Health.queryQuantitySamples() (current Scripting API).
|
|
// The old Health.read() / Health.Types.* / .quantity API was removed.
|
|
|
|
const SERVER_URL = "http://chronos:9432/ingest";
|
|
const API_KEY = "333f09ce904e9cbbe3b578fda393be92869122c7222dbccd";
|
|
|
|
// --- Units for each quantity type ---
|
|
const U = {
|
|
kg: HealthUnit.gram(HealthMetricPrefix.kilo),
|
|
mmHg: HealthUnit.millimeterOfMercury(),
|
|
bpm: HealthUnit.count().divided(HealthUnit.minute()),
|
|
count: HealthUnit.count(),
|
|
percent: HealthUnit.percent()
|
|
};
|
|
|
|
// --- Helpers ---
|
|
|
|
// Read the most recent sample of a type. Returns { value, startDate } or null.
|
|
async function readLatest(type, unit) {
|
|
try {
|
|
const samples = await Health.queryQuantitySamples(type, {
|
|
limit: 1,
|
|
sortDescriptors: [{ key: "startDate", order: "reverse" }]
|
|
});
|
|
if (!samples || samples.length === 0) return null;
|
|
const s = samples[0];
|
|
return { value: s.quantityValue(unit), startDate: s.startDate };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Sum all samples of a type since midnight. Returns a number or null.
|
|
async function readTodaySum(type, unit) {
|
|
try {
|
|
const start = new Date();
|
|
start.setHours(0, 0, 0, 0);
|
|
const samples = await Health.queryQuantitySamples(type, {
|
|
startDate: start,
|
|
endDate: new Date()
|
|
});
|
|
if (!samples || samples.length === 0) return null;
|
|
let total = 0;
|
|
for (const s of samples) {
|
|
total += s.quantityValue(unit);
|
|
}
|
|
return total;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isoNow() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function todayDate() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function notify(body) {
|
|
const n = new Notification();
|
|
n.title = "Health Sync";
|
|
n.body = body;
|
|
n.schedule();
|
|
}
|
|
|
|
// --- Read all health metrics ---
|
|
const weight = await readLatest("bodyMass", U.kg);
|
|
const bpSys = await readLatest("bloodPressureSystolic", U.mmHg);
|
|
const bpDia = await readLatest("bloodPressureDiastolic", U.mmHg);
|
|
const hr = await readLatest("heartRate", U.bpm);
|
|
const restingHr = await readLatest("restingHeartRate", U.bpm);
|
|
const steps = await readTodaySum("stepCount", U.count);
|
|
const spo2 = await readLatest("oxygenSaturation", U.percent);
|
|
|
|
// --- Build samples array — only include metrics that have data ---
|
|
const samples = [];
|
|
|
|
if (weight) {
|
|
samples.push({
|
|
type: "weight",
|
|
timestamp_utc: weight.startDate.toISOString(),
|
|
value: weight.value,
|
|
source: "apple_health"
|
|
});
|
|
}
|
|
|
|
if (bpSys && bpDia) {
|
|
const bpValue = { systolic: bpSys.value, diastolic: bpDia.value };
|
|
if (hr) bpValue.pulse = hr.value;
|
|
samples.push({
|
|
type: "blood_pressure",
|
|
timestamp_utc: bpSys.startDate.toISOString(),
|
|
value: bpValue,
|
|
source: "omron"
|
|
});
|
|
}
|
|
|
|
if (hr && !bpSys) {
|
|
samples.push({
|
|
type: "heart_rate",
|
|
timestamp_utc: hr.startDate.toISOString(),
|
|
value: hr.value,
|
|
source: "apple_health"
|
|
});
|
|
}
|
|
|
|
if (restingHr) {
|
|
samples.push({
|
|
type: "resting_heart_rate",
|
|
timestamp_utc: restingHr.startDate.toISOString(),
|
|
value: restingHr.value,
|
|
source: "apple_health"
|
|
});
|
|
}
|
|
|
|
if (steps !== null) {
|
|
samples.push({
|
|
type: "steps",
|
|
timestamp_utc: todayDate(),
|
|
value: steps,
|
|
source: "apple_health"
|
|
});
|
|
}
|
|
|
|
if (spo2) {
|
|
samples.push({
|
|
type: "spo2",
|
|
timestamp_utc: spo2.startDate.toISOString(),
|
|
value: spo2.value * 100, // HealthKit stores 0-1, server expects %
|
|
source: "apple_health"
|
|
});
|
|
}
|
|
|
|
if (samples.length === 0) {
|
|
await notify("No health data found today");
|
|
Script.done();
|
|
}
|
|
|
|
// --- POST to server ---
|
|
const req = new Request(SERVER_URL);
|
|
req.method = "POST";
|
|
req.headers = {
|
|
"Authorization": `Bearer ${API_KEY}`,
|
|
"Content-Type": "application/json"
|
|
};
|
|
req.body = JSON.stringify({ samples: samples });
|
|
|
|
try {
|
|
const res = await req.loadJSON();
|
|
await notify(`✓ Sent ${res.ingested || samples.length} samples`);
|
|
} catch (e) {
|
|
await notify(`✗ Error: ${e.message || e}`);
|
|
} |