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
356
examples/cards/templates/weather-live/card.js
Normal file
356
examples/cards/templates/weather-live/card.js
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
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 subtitleEl = root.querySelector('[data-weather-subtitle]');
|
||||
const tempEl = root.querySelector('[data-weather-temp]');
|
||||
const unitEl = root.querySelector('[data-weather-unit]');
|
||||
const humidityEl = root.querySelector('[data-weather-humidity]');
|
||||
const windEl = root.querySelector('[data-weather-wind]');
|
||||
const rainEl = root.querySelector('[data-weather-rain]');
|
||||
const uvEl = root.querySelector('[data-weather-uv]');
|
||||
const statusEl = root.querySelector('[data-weather-status]');
|
||||
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl 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 configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
|
||||
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.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 uvName = typeof state.uv_name === 'string' ? state.uv_name.trim() : '';
|
||||
const morningStartHourRaw = Number(state.morning_start_hour);
|
||||
const morningEndHourRaw = Number(state.morning_end_hour);
|
||||
const morningScoreRaw = Number(state.morning_score);
|
||||
const defaultScoreRaw = Number(state.default_score);
|
||||
const refreshMsRaw = Number(state.refresh_ms);
|
||||
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
|
||||
const morningStartHour = Number.isFinite(morningStartHourRaw) ? morningStartHourRaw : 6;
|
||||
const morningEndHour = Number.isFinite(morningEndHourRaw) ? morningEndHourRaw : 11;
|
||||
const morningScore = Number.isFinite(morningScoreRaw) ? morningScoreRaw : 84;
|
||||
const defaultScore = Number.isFinite(defaultScoreRaw) ? defaultScoreRaw : 38;
|
||||
|
||||
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
|
||||
const updateLiveContent = (snapshot) => {
|
||||
host.setLiveContent(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 stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
|
||||
|
||||
const extractExecJson = (toolResult) => {
|
||||
const parsedText = stripExecFooter(toolResult?.content);
|
||||
if (!parsedText) return null;
|
||||
try {
|
||||
return JSON.parse(parsedText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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 resolveForecastBundle = async () => {
|
||||
if (!forecastCommand) return null;
|
||||
const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
|
||||
command: forecastCommand,
|
||||
max_output_chars: 200000,
|
||||
});
|
||||
const payload = extractExecJson(toolResult);
|
||||
return payload && typeof payload === 'object' ? payload : null;
|
||||
};
|
||||
|
||||
const firstForecastEntry = (bundle, key, metricKey = '') => {
|
||||
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
|
||||
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
|
||||
if (!metricKey) {
|
||||
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
|
||||
}
|
||||
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
|
||||
};
|
||||
|
||||
const estimateUvIndex = (cloudCoverage) => {
|
||||
const now = new Date();
|
||||
const hour = now.getHours() + now.getMinutes() / 60;
|
||||
const daylightPhase = Math.sin(((hour - 6) / 12) * Math.PI);
|
||||
if (!Number.isFinite(daylightPhase) || daylightPhase <= 0) return 0;
|
||||
const normalizedCloudCoverage = Number.isFinite(cloudCoverage)
|
||||
? Math.min(Math.max(cloudCoverage, 0), 100)
|
||||
: null;
|
||||
const cloudFactor = normalizedCloudCoverage === null
|
||||
? 1
|
||||
: Math.max(0.2, 1 - normalizedCloudCoverage * 0.0065);
|
||||
return Math.max(0, Math.round(7 * daylightPhase * cloudFactor));
|
||||
};
|
||||
|
||||
const computeWeatherScore = () => {
|
||||
const now = new Date();
|
||||
const hour = now.getHours() + now.getMinutes() / 60;
|
||||
if (hour >= morningStartHour && hour < morningEndHour) return morningScore;
|
||||
return defaultScore;
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
const resolvedToolName = await resolveToolName();
|
||||
if (!resolvedToolName) {
|
||||
const errorText = 'Missing tool_name';
|
||||
setStatus('No tool', 'var(--theme-status-danger)');
|
||||
updateLiveContent({
|
||||
kind: 'weather',
|
||||
subtitle: subtitleEl.textContent || null,
|
||||
tool_name: null,
|
||||
temperature: null,
|
||||
temperature_unit: String(state.unit || '°F'),
|
||||
humidity: null,
|
||||
wind: null,
|
||||
rain: null,
|
||||
uv: null,
|
||||
status: 'No tool',
|
||||
error: errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Refreshing', 'var(--theme-status-muted)');
|
||||
try {
|
||||
const [toolResult, forecastBundle] = await Promise.all([
|
||||
host.callTool(resolvedToolName, {}),
|
||||
resolveForecastBundle(),
|
||||
]);
|
||||
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 uvSensorEntry = findEntry(entries, [
|
||||
uvName,
|
||||
`${prefix} UV index`,
|
||||
]);
|
||||
|
||||
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');
|
||||
|
||||
const humidity = Number(humidityEntry?.state);
|
||||
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
|
||||
|
||||
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
|
||||
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
|
||||
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
|
||||
const uvSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
|
||||
|
||||
const windSpeed = Number(nwsEntry?.wind_speed);
|
||||
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
|
||||
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
|
||||
|
||||
const rainChance = Number(nwsEntry?.precipitation_probability);
|
||||
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
|
||||
|
||||
const liveUvValue = Number(uvSensorEntry?.state);
|
||||
const forecastUvValue = Number(uvEntry?.uv_index);
|
||||
const cloudCoverage = Number.isFinite(Number(nwsEntry?.cloud_coverage))
|
||||
? Number(nwsEntry?.cloud_coverage)
|
||||
: Number(uvSource?.forecast?.[0]?.cloud_coverage);
|
||||
const estimatedUvValue = estimateUvIndex(cloudCoverage);
|
||||
const uvValue = Number.isFinite(liveUvValue)
|
||||
? liveUvValue
|
||||
: (Number.isFinite(forecastUvValue) ? forecastUvValue : estimatedUvValue);
|
||||
const uvEstimated = !Number.isFinite(liveUvValue) && !Number.isFinite(forecastUvValue);
|
||||
uvEl.textContent = Number.isFinite(uvValue)
|
||||
? `${uvEstimated && uvValue > 0 ? '~' : ''}${Math.round(uvValue)}`
|
||||
: '--';
|
||||
|
||||
subtitleEl.textContent = subtitle || prefix || 'Weather';
|
||||
setStatus('Live', 'var(--theme-status-live)');
|
||||
updateLiveContent({
|
||||
kind: 'weather',
|
||||
subtitle: subtitleEl.textContent || null,
|
||||
tool_name: resolvedToolName,
|
||||
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
|
||||
temperature_unit: unitEl.textContent || null,
|
||||
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
|
||||
wind: windEl.textContent || null,
|
||||
rain: rainEl.textContent || null,
|
||||
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
|
||||
uv_estimated: uvEstimated,
|
||||
score: computeWeatherScore(),
|
||||
status: 'Live',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorText = String(error);
|
||||
setStatus('Unavailable', 'var(--theme-status-danger)');
|
||||
tempEl.textContent = '--';
|
||||
unitEl.textContent = String(state.unit || '°F');
|
||||
humidityEl.textContent = '--';
|
||||
windEl.textContent = '--';
|
||||
rainEl.textContent = '--';
|
||||
uvEl.textContent = '--';
|
||||
updateLiveContent({
|
||||
kind: 'weather',
|
||||
subtitle: subtitleEl.textContent || null,
|
||||
tool_name: resolvedToolName,
|
||||
temperature: null,
|
||||
temperature_unit: unitEl.textContent || null,
|
||||
humidity: null,
|
||||
wind: null,
|
||||
rain: null,
|
||||
uv: null,
|
||||
score: computeWeatherScore(),
|
||||
status: 'Unavailable',
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
host.setRefreshHandler(() => {
|
||||
void refresh();
|
||||
});
|
||||
void refresh();
|
||||
__setInterval(() => { void refresh(); }, refreshMs);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
host.setRefreshHandler(null);
|
||||
host.setLiveContent(null);
|
||||
host.clearSelection();
|
||||
for (const cleanup of __cleanup.splice(0)) cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"key": "weather-live",
|
||||
"title": "Live Weather",
|
||||
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.",
|
||||
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional uv_name, optional condition_label, optional morning_start_hour/morning_end_hour/morning_score/default_score, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload. If a live UV reading is unavailable, the card falls back to a clearly approximate current UV estimate.",
|
||||
"example_state": {
|
||||
"subtitle": "Weather",
|
||||
"tool_name": "mcp_home_assistant_GetLiveContext",
|
||||
|
|
@ -10,8 +10,13 @@
|
|||
"provider_prefix": "OpenWeatherMap",
|
||||
"temperature_name": "OpenWeatherMap Temperature",
|
||||
"humidity_name": "OpenWeatherMap Humidity",
|
||||
"uv_name": "OpenWeatherMap UV index",
|
||||
"condition_label": "Weather",
|
||||
"refresh_ms": 86400000
|
||||
"morning_start_hour": 6,
|
||||
"morning_end_hour": 11,
|
||||
"morning_score": 84,
|
||||
"default_score": 38,
|
||||
"refresh_ms": 300000
|
||||
},
|
||||
"created_at": "2026-03-11T04:12:48.601255+00:00",
|
||||
"updated_at": "2026-03-11T19:18:04.632189+00:00"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<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 data-weather-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);">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'BlexMono Nerd Font Mono';
|
||||
|
|
@ -9,322 +9,31 @@
|
|||
}
|
||||
</style>
|
||||
<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 data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
|
||||
<span data-weather-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; 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>
|
||||
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.28rem;">°F</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
|
||||
<div>
|
||||
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
|
||||
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
|
||||
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
|
||||
</div>
|
||||
<div>
|
||||
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
|
||||
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
|
||||
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
|
||||
</div>
|
||||
<div>
|
||||
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
|
||||
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
|
||||
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
|
||||
</div>
|
||||
<div>
|
||||
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
|
||||
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
|
||||
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</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 humidityEl = root.querySelector('[data-weather-humidity]');
|
||||
const windEl = root.querySelector('[data-weather-wind]');
|
||||
const rainEl = root.querySelector('[data-weather-rain]');
|
||||
const uvEl = root.querySelector('[data-weather-uv]');
|
||||
const statusEl = root.querySelector('[data-weather-status]');
|
||||
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl 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 configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
|
||||
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.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 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 stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
|
||||
|
||||
const extractExecJson = (toolResult) => {
|
||||
const parsedText = stripExecFooter(toolResult?.content);
|
||||
if (!parsedText) return null;
|
||||
try {
|
||||
return JSON.parse(parsedText);
|
||||
} catch {
|
||||
return 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 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 resolveForecastBundle = async () => {
|
||||
if (!forecastCommand) return null;
|
||||
const toolResult = await window.__nanobotCallTool?.(configuredForecastToolName || 'exec', {
|
||||
command: forecastCommand,
|
||||
max_output_chars: 200000,
|
||||
});
|
||||
const payload = extractExecJson(toolResult);
|
||||
return payload && typeof payload === 'object' ? payload : null;
|
||||
};
|
||||
|
||||
const firstForecastEntry = (bundle, key, metricKey = '') => {
|
||||
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
|
||||
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
|
||||
if (!metricKey) {
|
||||
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
|
||||
}
|
||||
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
const resolvedToolName = await resolveToolName();
|
||||
if (!resolvedToolName) {
|
||||
const errorText = 'Missing tool_name';
|
||||
setStatus('No tool', '#b91c1c');
|
||||
updateLiveContent({
|
||||
kind: 'weather',
|
||||
subtitle: subtitleEl.textContent || null,
|
||||
tool_name: null,
|
||||
temperature: null,
|
||||
temperature_unit: String(state.unit || '°F'),
|
||||
humidity: null,
|
||||
wind: null,
|
||||
rain: null,
|
||||
uv: null,
|
||||
status: 'No tool',
|
||||
error: errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Refreshing', '#6b7280');
|
||||
try {
|
||||
const [toolResult, forecastBundle] = await Promise.all([
|
||||
window.__nanobotCallTool?.(resolvedToolName, {}),
|
||||
resolveForecastBundle(),
|
||||
]);
|
||||
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 temperature = Number(temperatureEntry?.state);
|
||||
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
|
||||
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
|
||||
|
||||
const humidity = Number(humidityEntry?.state);
|
||||
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
|
||||
|
||||
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
|
||||
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
|
||||
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
|
||||
|
||||
const windSpeed = Number(nwsEntry?.wind_speed);
|
||||
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
|
||||
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
|
||||
|
||||
const rainChance = Number(nwsEntry?.precipitation_probability);
|
||||
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
|
||||
|
||||
const uvValue = Number(uvEntry?.uv_index);
|
||||
uvEl.textContent = Number.isFinite(uvValue) ? String(Math.round(uvValue)) : '--';
|
||||
|
||||
subtitleEl.textContent = subtitle || prefix || 'Weather';
|
||||
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,
|
||||
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
|
||||
wind: windEl.textContent || null,
|
||||
rain: rainEl.textContent || null,
|
||||
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
|
||||
status: 'Live',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorText = String(error);
|
||||
setStatus('Unavailable', '#b91c1c');
|
||||
tempEl.textContent = '--';
|
||||
unitEl.textContent = String(state.unit || '°F');
|
||||
humidityEl.textContent = '--';
|
||||
windEl.textContent = '--';
|
||||
rainEl.textContent = '--';
|
||||
uvEl.textContent = '--';
|
||||
updateLiveContent({
|
||||
kind: 'weather',
|
||||
subtitle: subtitleEl.textContent || null,
|
||||
tool_name: resolvedToolName,
|
||||
temperature: null,
|
||||
temperature_unit: unitEl.textContent || null,
|
||||
humidity: null,
|
||||
wind: null,
|
||||
rain: null,
|
||||
uv: null,
|
||||
status: 'Unavailable',
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.__nanobotSetCardRefresh?.(script, () => {
|
||||
void refresh();
|
||||
});
|
||||
void refresh();
|
||||
window.setInterval(() => { void refresh(); }, refreshMs);
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue