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-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); const alertOnly = state.alert_only === true; const elevatedScoreRaw = Number(state.alert_score_elevated); const highScoreRaw = Number(state.alert_score_high); const elevatedScore = Number.isFinite(elevatedScoreRaw) ? elevatedScoreRaw : 88; const highScore = Number.isFinite(highScoreRaw) ? highScoreRaw : 98; subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data'; unitEl.textContent = fallbackUnit || '--'; const updateLiveContent = (snapshot) => { host.setLiveContent(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 (!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 classify = (value) => { if (!Number.isFinite(value)) return { label: 'Unavailable', color: 'var(--theme-status-danger)' }; if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: 'var(--theme-status-danger)' }; if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: 'var(--theme-status-warning)' }; return { label: 'Good', color: 'var(--theme-status-live)' }; }; const computeAlertVisibility = (statusLabel) => { if (!alertOnly) return false; return statusLabel === 'Good' || statusLabel === 'Unavailable'; }; const computeAlertScore = (statusLabel, value) => { if (!alertOnly) return Number.isFinite(value) ? 0 : null; if (statusLabel === 'High') return highScore; if (statusLabel === 'Elevated') return elevatedScore; return 0; }; const refresh = async () => { const resolvedToolName = await resolveToolName(); if (!resolvedToolName) { const errorText = 'Missing tool_name'; valueEl.textContent = '--'; setStatus('No tool', 'var(--theme-status-danger)'); 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', 'var(--theme-status-muted)'); try { const toolResult = await host.callTool(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, score: computeAlertScore(status.label, numericValue), hidden: computeAlertVisibility(status.label), }); } catch (error) { const errorText = String(error); valueEl.textContent = '--'; setStatus('Unavailable', 'var(--theme-status-danger)'); 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, score: alertOnly ? 0 : null, hidden: alertOnly, }); } }; 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(); }, }; }