feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
218
examples/cards/templates/live-bedroom-co2/card.js
Normal file
218
examples/cards/templates/live-bedroom-co2/card.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
export function mount({ root, state, host }) {
|
||||
state = state || {};
|
||||
const __cleanup = [];
|
||||
const __setInterval = (...args) => {
|
||||
const id = window.setInterval(...args);
|
||||
__cleanup.push(() => window.clearInterval(id));
|
||||
return id;
|
||||
};
|
||||
const __setTimeout = (...args) => {
|
||||
const id = window.setTimeout(...args);
|
||||
__cleanup.push(() => window.clearTimeout(id));
|
||||
return id;
|
||||
};
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
|
||||
const valueEl = root.querySelector("[data-co2-value]");
|
||||
const statusEl = root.querySelector("[data-co2-status]");
|
||||
const updatedEl = root.querySelector("[data-co2-updated]");
|
||||
if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
|
||||
|
||||
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
|
||||
const INTERVAL_RAW = Number(state.refresh_ms);
|
||||
const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
|
||||
|
||||
const setStatus = (label, color) => {
|
||||
statusEl.textContent = label;
|
||||
statusEl.style.color = color;
|
||||
};
|
||||
|
||||
const setUpdatedNow = () => {
|
||||
updatedEl.textContent = new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const parseValue = (raw) => {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? Math.round(n) : null;
|
||||
};
|
||||
|
||||
const computeScore = (value) => {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
if (value >= 1600) return 96;
|
||||
if (value >= 1400) return 90;
|
||||
if (value >= 1200) return 82;
|
||||
if (value >= 1000) return 68;
|
||||
if (value >= 900) return 54;
|
||||
if (value >= 750) return 34;
|
||||
return 16;
|
||||
};
|
||||
|
||||
const stripQuotes = (value) => {
|
||||
const text = String(value ?? '').trim();
|
||||
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const normalizeText = (value) => String(value || '').trim().toLowerCase();
|
||||
|
||||
const parseLiveContextEntries = (payloadText) => {
|
||||
const text = String(payloadText || '').replace(/\r/g, '');
|
||||
const startIndex = text.indexOf('- names: ');
|
||||
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
|
||||
const entries = [];
|
||||
let current = null;
|
||||
let inAttributes = false;
|
||||
|
||||
const pushCurrent = () => {
|
||||
if (current) entries.push(current);
|
||||
current = null;
|
||||
inAttributes = false;
|
||||
};
|
||||
|
||||
for (const rawLine of relevant.split('\n')) {
|
||||
if (rawLine.startsWith('- names: ')) {
|
||||
pushCurrent();
|
||||
current = {
|
||||
name: stripQuotes(rawLine.slice(9)),
|
||||
domain: '',
|
||||
state: '',
|
||||
attributes: {},
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed === 'attributes:') {
|
||||
inAttributes = true;
|
||||
continue;
|
||||
}
|
||||
if (rawLine.startsWith(' domain:')) {
|
||||
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||
inAttributes = false;
|
||||
continue;
|
||||
}
|
||||
if (rawLine.startsWith(' state:')) {
|
||||
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||
inAttributes = false;
|
||||
continue;
|
||||
}
|
||||
if (inAttributes && rawLine.startsWith(' ')) {
|
||||
const separatorIndex = rawLine.indexOf(':');
|
||||
if (separatorIndex >= 0) {
|
||||
const key = rawLine.slice(4, separatorIndex).trim();
|
||||
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
|
||||
current.attributes[key] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
inAttributes = false;
|
||||
}
|
||||
|
||||
pushCurrent();
|
||||
return entries;
|
||||
};
|
||||
|
||||
const extractLiveContextText = (toolResult) => {
|
||||
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||
const parsed = toolResult.parsed;
|
||||
if (typeof parsed.result === 'string') return parsed.result;
|
||||
}
|
||||
if (typeof toolResult?.content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(toolResult.content);
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
|
||||
return parsed.result;
|
||||
}
|
||||
} catch {
|
||||
return toolResult.content;
|
||||
}
|
||||
return toolResult.content;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveToolName = async () => {
|
||||
if (configuredToolName) return configuredToolName;
|
||||
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
|
||||
try {
|
||||
const tools = await host.listTools();
|
||||
const liveContextTool = Array.isArray(tools)
|
||||
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
|
||||
: null;
|
||||
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
|
||||
} catch {
|
||||
return 'mcp_home_assistant_GetLiveContext';
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setStatus("Refreshing", "var(--theme-status-muted)");
|
||||
try {
|
||||
const toolName = await resolveToolName();
|
||||
const toolResult = await host.callTool(toolName, {});
|
||||
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
|
||||
const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
|
||||
if (!entry) throw new Error(`Missing sensor ${matchName}`);
|
||||
const value = parseValue(entry.state);
|
||||
if (value === null) throw new Error("Invalid sensor payload");
|
||||
|
||||
valueEl.textContent = String(value);
|
||||
if (value >= 1200) setStatus("High", "var(--theme-status-danger)");
|
||||
else if (value >= 900) setStatus("Elevated", "var(--theme-status-warning)");
|
||||
else setStatus("Good", "var(--theme-status-live)");
|
||||
setUpdatedNow();
|
||||
host.setLiveContent({
|
||||
kind: 'sensor',
|
||||
tool_name: toolName,
|
||||
match_name: entry.name,
|
||||
value,
|
||||
display_value: String(value),
|
||||
unit: entry.attributes?.unit_of_measurement || 'ppm',
|
||||
status: statusEl.textContent || null,
|
||||
updated_at: updatedEl.textContent || null,
|
||||
score: computeScore(value),
|
||||
});
|
||||
} catch (err) {
|
||||
valueEl.textContent = "--";
|
||||
setStatus("Unavailable", "var(--theme-status-danger)");
|
||||
updatedEl.textContent = String(err);
|
||||
host.setLiveContent({
|
||||
kind: 'sensor',
|
||||
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
|
||||
match_name: matchName,
|
||||
value: null,
|
||||
display_value: '--',
|
||||
unit: 'ppm',
|
||||
status: 'Unavailable',
|
||||
updated_at: String(err),
|
||||
error: String(err),
|
||||
score: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
host.setRefreshHandler(() => {
|
||||
void refresh();
|
||||
});
|
||||
void refresh();
|
||||
__setInterval(() => {
|
||||
void refresh();
|
||||
}, INTERVAL_MS);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
host.setRefreshHandler(null);
|
||||
host.setLiveContent(null);
|
||||
host.clearSelection();
|
||||
for (const cleanup of __cleanup.splice(0)) cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue