309 lines
12 KiB
JavaScript
309 lines
12 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 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();
|
|
},
|
|
};
|
|
}
|