Add clinical device registry, browser wedge scan, and local agent API.
Deploy Ladill Care / deploy (push) Successful in 1m39s
Deploy Ladill Care / deploy (push) Successful in 1m39s
Branch-scoped Care devices with hashed agent tokens, patient barcode lookup/labels, and provenance-aware vitals from a minimal device agent (Pro for agent hardware; wedge free). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Care API base (no trailing slash)
|
||||
CARE_URL=https://care.ladill.com
|
||||
|
||||
# Plaintext token from Settings → Devices (shown once on create/regenerate)
|
||||
DEVICE_TOKEN=
|
||||
|
||||
# Open consultation UUID to receive demo vitals
|
||||
CONSULTATION_UUID=
|
||||
|
||||
# Post a fake temperature reading when set (demo without serial hardware)
|
||||
FAKE_READING=1
|
||||
|
||||
# Seconds between heartbeat / optional reading cycles
|
||||
INTERVAL_SECONDS=30
|
||||
|
||||
# Optional agent identity
|
||||
AGENT_HOSTNAME=
|
||||
AGENT_VERSION=0.1.0
|
||||
@@ -0,0 +1,13 @@
|
||||
# Care Device Agent (demo)
|
||||
|
||||
Minimal Node agent that authenticates with `X-Care-Device-Token` and posts heartbeats / stub vitals.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit CARE_URL, DEVICE_TOKEN, CONSULTATION_UUID
|
||||
FAKE_READING=1 npm start
|
||||
# or one shot:
|
||||
FAKE_READING=1 npm run once
|
||||
```
|
||||
|
||||
See `docs/devices.md` in the Care repo for API details.
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Minimal Care Device Agent.
|
||||
*
|
||||
* Demo mode: FAKE_READING=1 posts stub vitals (no serial port).
|
||||
* Production: replace readSerialStub() with a vendor/serial/BLE reader.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadEnvFile() {
|
||||
const envPath = path.join(__dirname, '.env');
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let val = trimmed.slice(eq + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile();
|
||||
|
||||
const CARE_URL = (process.env.CARE_URL || '').replace(/\/$/, '');
|
||||
const DEVICE_TOKEN = process.env.DEVICE_TOKEN || '';
|
||||
const CONSULTATION_UUID = process.env.CONSULTATION_UUID || '';
|
||||
const FAKE_READING = ['1', 'true', 'yes'].includes(String(process.env.FAKE_READING || '').toLowerCase());
|
||||
const INTERVAL_SECONDS = Math.max(5, Number(process.env.INTERVAL_SECONDS || 30));
|
||||
const AGENT_VERSION = process.env.AGENT_VERSION || '0.1.0';
|
||||
const AGENT_HOSTNAME = process.env.AGENT_HOSTNAME || require('os').hostname();
|
||||
const ONCE = process.argv.includes('--once');
|
||||
|
||||
if (!CARE_URL || !DEVICE_TOKEN) {
|
||||
console.error('Set CARE_URL and DEVICE_TOKEN (see .env.example).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function api(pathname, body) {
|
||||
const res = await fetch(`${CARE_URL}/api/v1/device-agent${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Care-Device-Token': DEVICE_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Stub serial read — replace with real driver for production. */
|
||||
function readSerialStub() {
|
||||
if (!FAKE_READING) return null;
|
||||
const base = 36.5 + Math.random() * 0.8;
|
||||
return {
|
||||
vitals: {
|
||||
temperature: Math.round(base * 10) / 10,
|
||||
pulse: 60 + Math.floor(Math.random() * 20),
|
||||
spo2: 96 + Math.floor(Math.random() * 4),
|
||||
},
|
||||
raw: {
|
||||
driver: 'fake',
|
||||
captured_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
const hb = await api('/heartbeat', {
|
||||
agent_version: AGENT_VERSION,
|
||||
hostname: AGENT_HOSTNAME,
|
||||
});
|
||||
console.log(`[heartbeat] status=${hb.status} last_seen=${hb.last_seen_at}`);
|
||||
|
||||
const reading = readSerialStub();
|
||||
if (reading && CONSULTATION_UUID) {
|
||||
const result = await api('/vitals', {
|
||||
consultation_uuid: CONSULTATION_UUID,
|
||||
vitals: reading.vitals,
|
||||
raw: reading.raw,
|
||||
});
|
||||
console.log(`[vitals] id=${result.vital_sign_id} temp=${reading.vitals.temperature}`);
|
||||
} else if (reading && !CONSULTATION_UUID) {
|
||||
console.log('[vitals] skipped — set CONSULTATION_UUID to post fake readings');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Care Device Agent → ${CARE_URL} (fake=${FAKE_READING ? 'on' : 'off'})`);
|
||||
await tick();
|
||||
if (ONCE) return;
|
||||
setInterval(() => {
|
||||
tick().catch((err) => console.error('[error]', err.message, err.data || ''));
|
||||
}, INTERVAL_SECONDS * 1000);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message, err.data || '');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "care-device-agent",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Minimal Ladill Care Device Agent — heartbeat + stub vitals for demos",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"once": "node index.js --once"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user