198 lines
10 KiB
JavaScript
198 lines
10 KiB
JavaScript
// HOMECORE-UI API client — ADR-131 §2 / §11.
|
|
//
|
|
// Production path: every method issues a SAME-ORIGIN request to the
|
|
// homecore-server BFF gateway (§2.1). There is NO mock fallback in
|
|
// production — a failed upstream rejects, and the panel renders a typed
|
|
// error/empty state (§2.2, §11.11). The in-browser mock layer is a
|
|
// DEV-ONLY fixture, reachable only when demo mode is on:
|
|
// ?demo=1 in the URL, globalThis.HOMECORE_UI_DEMO, or
|
|
// localStorage 'homecore_demo' = '1'.
|
|
//
|
|
// Gateway route map: ADR-131 §11.2.
|
|
|
|
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
|
|
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
|
|
// after first use so repeated demo calls don't re-import.
|
|
let _mock = null;
|
|
async function loadMock() {
|
|
if (!_mock) _mock = await import('./mock.js');
|
|
return _mock;
|
|
}
|
|
|
|
const demoFlags = {};
|
|
|
|
/** Demo mode = explicit dev opt-in only; never the production default. */
|
|
export function demoMode() {
|
|
try { if (typeof location !== 'undefined' && /[?&]demo=1(\b|&|$)/.test(location.search || '')) return true; } catch {}
|
|
try { if (typeof globalThis !== 'undefined' && globalThis.HOMECORE_UI_DEMO) return true; } catch {}
|
|
try { if (typeof localStorage !== 'undefined' && localStorage.getItem('homecore_demo') === '1') return true; } catch {}
|
|
return false;
|
|
}
|
|
|
|
export const api = {
|
|
base: '',
|
|
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
|
|
isDemo: (key) => !!demoFlags[key],
|
|
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
|
|
demoMode,
|
|
|
|
async _get(path) {
|
|
const r = await fetch(this.base + path, { headers: { Authorization: 'Bearer ' + this.token() } });
|
|
if (!r.ok) throw httpError(path, r.status);
|
|
return r.json();
|
|
},
|
|
async _post(path, body) {
|
|
const r = await fetch(this.base + path, {
|
|
method: 'POST',
|
|
headers: { Authorization: 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body || {}),
|
|
});
|
|
if (!r.ok) throw httpError(path, r.status);
|
|
return r.json();
|
|
},
|
|
async _delete(path) {
|
|
const r = await fetch(this.base + path, { method: 'DELETE', headers: { Authorization: 'Bearer ' + this.token() } });
|
|
if (!r.ok) throw httpError(path, r.status);
|
|
return r.status === 204 ? {} : r.json();
|
|
},
|
|
|
|
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
|
|
// The mock module is dynamically imported ONLY on the demo branch, so prod
|
|
// never loads it. `mockFn` receives the loaded module.
|
|
async _data(key, path, mockFn) {
|
|
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
|
|
delete demoFlags[key];
|
|
return this._get(path);
|
|
},
|
|
|
|
// ── homecore-api (real, already served) ───────────────────────────
|
|
async config() { return this._get('/api/config'); },
|
|
async states() {
|
|
if (demoMode()) { demoFlags.states = true; return demoEntities(); }
|
|
delete demoFlags.states;
|
|
return this._get('/api/states');
|
|
},
|
|
async services() { return this._data('services', '/api/services', () => []); },
|
|
async callService(domain, service, data) { return this._post(`/api/services/${domain}/${service}`, data); },
|
|
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
|
|
|
|
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
|
|
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
|
|
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
|
|
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
|
|
async esp32Warnings() {
|
|
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
|
|
const seeds = await this._get('/api/homecore/seeds');
|
|
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
|
|
},
|
|
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
|
|
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
|
|
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
|
|
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
|
|
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
|
|
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
|
|
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
|
|
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
|
|
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
|
|
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
|
|
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
|
|
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
|
|
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
|
|
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
|
|
|
|
// calibration (ADR-151) — real proxy in prod, simulated in demo.
|
|
calibration: makeCalibration(),
|
|
};
|
|
|
|
function httpError(path, status) {
|
|
const e = new Error(`${path} → HTTP ${status}`);
|
|
e.status = status;
|
|
e.upstreamUnavailable = status === 503 || status === 504;
|
|
return e;
|
|
}
|
|
|
|
// Demo-only entity fixture (prod path uses real GET /api/states).
|
|
function demoEntities() {
|
|
return [
|
|
{ entity_id: 'sensor.living_room_presence', state: 'true', attributes: { friendly_name: 'Living Room Presence', source: 'esp32-lr-01', seed: 'seed-livingroom-a1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-1', user_id: null, parent_id: null } },
|
|
{ entity_id: 'sensor.bedroom_1_breathing_rate', state: '14.5', attributes: { friendly_name: 'Bedroom 1 Breathing Rate', unit_of_measurement: 'BPM', source: 'esp32-br1-01', seed: 'seed-bedroom-1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-2', user_id: null, parent_id: 'ctx-1' } },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolve an entity's tier provenance (§4.4 / §11.9). Prefers the
|
|
* explicit `attributes.seed`/`attributes.cog` lineage that integrations
|
|
* are expected to stamp; falls back to parsing the ESP32 node id. In demo
|
|
* mode it may consult the mock node registry. Missing lineage → 'unknown'
|
|
* (never fabricated).
|
|
*/
|
|
export function entityProvenance(entity) {
|
|
const attrs = (entity && entity.attributes) || {};
|
|
const src = String(attrs.source || '');
|
|
const nodeMatch = src.match(/esp32[-\w]*/i);
|
|
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
|
|
let seed = attrs.seed || null;
|
|
// Demo-only enrichment: consult the mock node registry IF it has already
|
|
// been dynamically loaded by a prior demo data call (this fn is sync, so it
|
|
// cannot await the import). Prod never has `_mock` set → seed stays null
|
|
// (never fabricated).
|
|
if (!seed && demoMode() && node && _mock) {
|
|
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
|
|
seed = cfg ? cfg.seed : null;
|
|
}
|
|
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
|
|
const cog = attrs.cog || (/matter|bfld|mmwave|mr60/i.test(src) ? 'cog-ha-matter' : (hailo ? 'cog-pose-estimation' : null));
|
|
return { esp32: node, seed: seed || (node ? 'unknown' : null), cog: cog || 'unknown', hailo };
|
|
}
|
|
|
|
// Calibration: per-call branch on demo mode. Prod proxies the real
|
|
// calibrate-serve API via the gateway (/api/cal/v1/*). All methods are
|
|
// async (the §4.7 wizard awaits them).
|
|
function makeCalibration() {
|
|
const ANCHORS = ['empty', 'stand_still', 'sit', 'lie_down', 'breathe_slow', 'breathe_normal', 'small_move', 'sleep_posture'];
|
|
// demo session state
|
|
let frames = 0; const target = 1200; const accepted = new Set();
|
|
const get = (p) => api._get('/api/cal/v1' + p);
|
|
const post = (p, b) => api._post('/api/cal/v1' + p, b);
|
|
return {
|
|
ANCHORS,
|
|
get demo() { return demoMode(); },
|
|
async start() {
|
|
if (demoMode()) { frames = 0; return { baseline_id: 'bl-demo-' + ANCHORS.length }; }
|
|
return post('/calibration/start', {});
|
|
},
|
|
async stop() { if (demoMode()) return { stopped: true }; return post('/calibration/stop', {}); },
|
|
async status() {
|
|
if (demoMode()) { frames = Math.min(target, frames + 180); return { frames, target, eta_s: Math.max(0, Math.round((target - frames) / 180)), z_median: 0.41, motion_flagged: frames < 360 }; }
|
|
return get('/calibration/status');
|
|
},
|
|
async anchor(label) {
|
|
if (demoMode()) {
|
|
const ok = label !== 'sleep_posture' || accepted.size >= 6;
|
|
if (ok) accepted.add(label);
|
|
return { label, accepted: ok, reason: ok ? null : 'insufficient stillness — retry', features: { mean: 0.12, variance: 0.04, breathing_score: 0.7, heart_score: 0.55 } };
|
|
}
|
|
return post('/enroll/anchor', { label });
|
|
},
|
|
async enrollStatus() {
|
|
if (demoMode()) return { accepted: [...accepted], total: ANCHORS.length };
|
|
return get('/enroll/status');
|
|
},
|
|
async train(room_id) {
|
|
if (demoMode()) {
|
|
const trained = accepted.size >= 6;
|
|
return {
|
|
presence: trained ? { threshold: 0.31, occupied_var: 0.08 } : null,
|
|
posture: trained ? { prototypes: 4 } : null,
|
|
breathing: accepted.has('breathe_normal') ? { min_score: 0.6 } : null,
|
|
heartbeat: accepted.has('breathe_normal') ? { min_score: 0.5 } : null,
|
|
restlessness: trained ? { calm: 0.05, active: 0.6 } : null,
|
|
anomaly: trained ? { prototypes: 8, scale: 1.4 } : null,
|
|
};
|
|
}
|
|
return post('/room/train', { room_id });
|
|
},
|
|
reset() { accepted.clear(); frames = 0; },
|
|
};
|
|
}
|