nanobot-voice-interface/examples/cards/templates/live-weather-01545/card.js
kacper 4dfb7ca3cc
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s
feat: unify card runtime and event-driven web ui
2026-04-06 15:42:53 -04:00

243 lines
9.1 KiB
JavaScript

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 tempEl = root.querySelector("[data-weather-temp]");
const unitEl = root.querySelector("[data-weather-unit]");
const condEl = root.querySelector("[data-weather-condition]");
const humidityEl = root.querySelector("[data-weather-humidity]");
const windEl = root.querySelector("[data-weather-wind]");
const pressureEl = root.querySelector("[data-weather-pressure]");
const updatedEl = root.querySelector("[data-weather-updated]");
const statusEl = root.querySelector("[data-weather-status]");
if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
!(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
!(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const nowLabel = () => new Date().toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
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 findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
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)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [temperatureName]);
const humidityEntry = findEntry(entries, [humidityName]);
const pressureEntry = findEntry(entries, [pressureName]);
const windEntry = findEntry(entries, [windName]);
const tempNum = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
condEl.textContent = conditionLabel;
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
const pressureNum = Number(pressureEntry?.state);
pressureEl.textContent = Number.isFinite(pressureNum)
? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
: "--";
updatedEl.textContent = nowLabel();
setStatus("Live", "var(--theme-status-live)");
host.setLiveContent({
kind: 'weather',
tool_name: toolName,
provider_prefix: providerPrefix,
temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
pressure: pressureEl.textContent || null,
condition: condEl.textContent || null,
updated_at: updatedEl.textContent || null,
status: statusEl.textContent || null,
});
} catch (err) {
setStatus("Unavailable", "var(--theme-status-danger)");
updatedEl.textContent = String(err);
host.setLiveContent({
kind: 'weather',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
provider_prefix: providerPrefix,
temperature: null,
humidity: null,
wind: null,
pressure: null,
condition: null,
updated_at: String(err),
status: 'Unavailable',
error: String(err),
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => {
void refresh();
}, REFRESH_MS);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}