204 lines
7.2 KiB
HTML
204 lines
7.2 KiB
HTML
<div data-co2-card="bedroom" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 520px;">
|
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 12px;">
|
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Bedroom CO2</h2>
|
|
<span data-co2-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
|
|
</div>
|
|
|
|
<div style="display:flex; align-items:baseline; gap:8px;">
|
|
<span data-co2-value style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
|
|
<span style="font-size:1rem; font-weight:600; color:#4b5563;">ppm</span>
|
|
</div>
|
|
|
|
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
|
|
Updated: <span data-co2-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 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 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 (!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 refresh = async () => {
|
|
setStatus("Refreshing", "#6b7280");
|
|
try {
|
|
const toolName = await resolveToolName();
|
|
const toolResult = await window.__nanobotCallTool?.(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", "#b91c1c");
|
|
else if (value >= 900) setStatus("Elevated", "#b45309");
|
|
else setStatus("Good", "#047857");
|
|
setUpdatedNow();
|
|
window.__nanobotSetCardLiveContent?.(script, {
|
|
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,
|
|
});
|
|
} catch (err) {
|
|
valueEl.textContent = "--";
|
|
setStatus("Unavailable", "#b91c1c");
|
|
updatedEl.textContent = String(err);
|
|
window.__nanobotSetCardLiveContent?.(script, {
|
|
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),
|
|
});
|
|
}
|
|
};
|
|
|
|
window.__nanobotSetCardRefresh?.(script, () => {
|
|
void refresh();
|
|
});
|
|
void refresh();
|
|
window.setInterval(() => {
|
|
void refresh();
|
|
}, INTERVAL_MS);
|
|
})();
|
|
</script>
|