294 lines
12 KiB
HTML
294 lines
12 KiB
HTML
|
|
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
|
||
|
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
|
||
|
|
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
|
||
|
|
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style="display:flex; align-items:flex-end; gap:8px; margin-bottom:4px;">
|
||
|
|
<span data-weather-temp style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
|
||
|
|
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div data-weather-condition style="font-size:1rem; line-height:1.3; font-weight:700; color:#1f2937; margin-bottom:10px; text-transform:capitalize;">--</div>
|
||
|
|
|
||
|
|
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
|
||
|
|
<div>
|
||
|
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Humidity</div>
|
||
|
|
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Wind</div>
|
||
|
|
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Pressure</div>
|
||
|
|
<div data-weather-pressure style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Updated</div>
|
||
|
|
<div data-weather-updated style="margin-top:2px; font-size:0.94rem; line-height:1.25; font-weight:700; color:#374151;">--</div>
|
||
|
|
</div>
|
||
|
|
</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-weather-subtitle]');
|
||
|
|
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 (!(subtitleEl instanceof HTMLElement) || !(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 subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
|
||
|
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||
|
|
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
|
||
|
|
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
|
||
|
|
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
|
||
|
|
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : '';
|
||
|
|
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : '';
|
||
|
|
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : '';
|
||
|
|
const refreshMsRaw = Number(state.refresh_ms);
|
||
|
|
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
|
||
|
|
|
||
|
|
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
|
||
|
|
const updateLiveContent = (snapshot) => {
|
||
|
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||
|
|
};
|
||
|
|
|
||
|
|
const setStatus = (label, color) => {
|
||
|
|
statusEl.textContent = label;
|
||
|
|
statusEl.style.color = color;
|
||
|
|
};
|
||
|
|
|
||
|
|
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 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 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 () => {
|
||
|
|
const resolvedToolName = await resolveToolName();
|
||
|
|
if (!resolvedToolName) {
|
||
|
|
const errorText = 'Missing tool_name';
|
||
|
|
setStatus('No tool', '#b91c1c');
|
||
|
|
updatedEl.textContent = errorText;
|
||
|
|
updateLiveContent({
|
||
|
|
kind: 'weather',
|
||
|
|
subtitle: subtitleEl.textContent || null,
|
||
|
|
tool_name: null,
|
||
|
|
temperature: null,
|
||
|
|
temperature_unit: String(state.unit || '°F'),
|
||
|
|
condition: null,
|
||
|
|
humidity: null,
|
||
|
|
wind: null,
|
||
|
|
pressure: 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)).filter((entry) => normalizeText(entry.domain) === 'sensor');
|
||
|
|
const prefix = providerPrefix || 'OpenWeatherMap';
|
||
|
|
const temperatureEntry = findEntry(entries, [
|
||
|
|
temperatureName,
|
||
|
|
`${prefix} Temperature`,
|
||
|
|
]);
|
||
|
|
const humidityEntry = findEntry(entries, [
|
||
|
|
humidityName,
|
||
|
|
`${prefix} Humidity`,
|
||
|
|
]);
|
||
|
|
const pressureEntry = findEntry(entries, [
|
||
|
|
pressureName,
|
||
|
|
`${prefix} Pressure`,
|
||
|
|
]);
|
||
|
|
const windEntry = findEntry(entries, [
|
||
|
|
windName,
|
||
|
|
`${prefix} Wind speed`,
|
||
|
|
`${prefix} Wind`,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const temperature = Number(temperatureEntry?.state);
|
||
|
|
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
|
||
|
|
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
|
||
|
|
condEl.textContent = conditionLabel || `${prefix || 'Weather'} live context`;
|
||
|
|
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 pressure = Number(pressureEntry?.state);
|
||
|
|
pressureEl.textContent = Number.isFinite(pressure)
|
||
|
|
? `${pressure} ${String(pressureEntry?.attributes?.unit_of_measurement || '').trim()}`.trim()
|
||
|
|
: '--';
|
||
|
|
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
|
|
updatedEl.textContent = updatedText;
|
||
|
|
subtitleEl.textContent = subtitle || prefix || 'Home Assistant live context';
|
||
|
|
setStatus('Live', '#047857');
|
||
|
|
updateLiveContent({
|
||
|
|
kind: 'weather',
|
||
|
|
subtitle: subtitleEl.textContent || null,
|
||
|
|
tool_name: resolvedToolName,
|
||
|
|
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
|
||
|
|
temperature_unit: unitEl.textContent || null,
|
||
|
|
condition: condEl.textContent || null,
|
||
|
|
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
|
||
|
|
wind: windEl.textContent || null,
|
||
|
|
pressure: pressureEl.textContent || null,
|
||
|
|
status: 'Live',
|
||
|
|
updated_at: updatedText,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
const errorText = String(error);
|
||
|
|
setStatus('Unavailable', '#b91c1c');
|
||
|
|
updatedEl.textContent = errorText;
|
||
|
|
updateLiveContent({
|
||
|
|
kind: 'weather',
|
||
|
|
subtitle: subtitleEl.textContent || null,
|
||
|
|
tool_name: resolvedToolName,
|
||
|
|
temperature: null,
|
||
|
|
temperature_unit: unitEl.textContent || null,
|
||
|
|
condition: null,
|
||
|
|
humidity: null,
|
||
|
|
wind: null,
|
||
|
|
pressure: null,
|
||
|
|
status: 'Unavailable',
|
||
|
|
updated_at: errorText,
|
||
|
|
error: errorText,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||
|
|
void refresh();
|
||
|
|
});
|
||
|
|
void refresh();
|
||
|
|
window.setInterval(() => { void refresh(); }, refreshMs);
|
||
|
|
})();
|
||
|
|
</script>
|