feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

View file

@ -1,287 +1,15 @@
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div style="display:flex; align-items:flex-end; gap:8px;">
<span data-sensor-value style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">--</span>
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.28rem;">--</span>
</div>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">
Updated <span data-sensor-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-sensor-subtitle]');
const valueEl = root.querySelector('[data-sensor-value]');
const unitEl = root.querySelector('[data-sensor-unit]');
const statusEl = root.querySelector('[data-sensor-status]');
const updatedEl = root.querySelector('[data-sensor-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
const matchNames = Array.isArray(state.match_names)
? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const searchTerms = Array.isArray(state.search_terms)
? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
const decimalsRaw = Number(state.value_decimals);
const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
const goodMax = Number(thresholds.good_max);
const elevatedMax = Number(thresholds.elevated_max);
subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
unitEl.textContent = fallbackUnit || '--';
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const renderValue = (value) => {
if (!Number.isFinite(value)) return '--';
return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
};
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: '',
areas: '',
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 (rawLine.startsWith(' areas:')) {
current.areas = 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 allMatchNames = [matchName, ...matchNames].filter(Boolean);
const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
const scoreEntry = (entry) => {
if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
const entryName = normalizeText(entry.name);
let score = 0;
for (const candidate of allMatchNames) {
const normalized = normalizeText(candidate);
if (!normalized) continue;
if (entryName === normalized) score += 100;
else if (entryName.includes(normalized)) score += 40;
}
for (const term of allSearchTerms) {
const normalized = normalizeText(term);
if (!normalized) continue;
if (entryName.includes(normalized)) score += 10;
}
const entryDeviceClass = normalizeText(entry.attributes?.device_class);
if (deviceClass && entryDeviceClass === deviceClass) score += 30;
if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
return score;
};
const findSensorEntry = (entries) => {
const scored = entries
.map((entry) => ({ entry, score: scoreEntry(entry) }))
.filter((item) => Number.isFinite(item.score) && item.score > 0)
.sort((left, right) => right.score - left.score);
return scored.length > 0 ? scored[0].entry : null;
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
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 classify = (value) => {
if (!Number.isFinite(value)) return { label: 'Unavailable', color: '#b91c1c' };
if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: '#b91c1c' };
if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: '#b45309' };
return { label: 'Good', color: '#047857' };
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
valueEl.textContent = '--';
setStatus('No tool', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: null,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'No tool',
updated_at: errorText,
error: errorText,
});
return;
}
setStatus('Refreshing', '#6b7280');
try {
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = findSensorEntry(entries);
if (!entry) throw new Error('Matching sensor not found in live context');
const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
const numericValue = Number(entry.state);
const renderedValue = renderValue(numericValue);
valueEl.textContent = renderedValue;
const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
unitEl.textContent = unit;
subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
const status = classify(numericValue);
setStatus(status.label, status.color);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: entry.name || matchName || matchNames[0] || null,
value: Number.isFinite(numericValue) ? numericValue : null,
display_value: renderedValue,
unit,
status: status.label,
updated_at: updatedText,
});
} catch (error) {
const errorText = String(error);
valueEl.textContent = '--';
setStatus('Unavailable', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'Unavailable',
updated_at: errorText,
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>