931 lines
47 KiB
JavaScript
931 lines
47 KiB
JavaScript
function setDashboardTab(tab, { scroll = true } = {}) {
|
|
const next = ['main', 'thermostats', 'manual'].includes(tab) ? tab : 'main';
|
|
app.dashboardTab = next;
|
|
$$('[data-dashboard-tab]').forEach(button => {
|
|
const active = button.dataset.dashboardTab === next;
|
|
button.classList.toggle('active', active);
|
|
button.setAttribute('aria-selected', String(active));
|
|
});
|
|
$$('[data-dashboard-panel]').forEach(panel => {
|
|
const active = panel.dataset.dashboardPanel === next;
|
|
panel.classList.toggle('active', active);
|
|
panel.hidden = !active;
|
|
});
|
|
if (scroll && app.currentView === 'dashboard') window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
function showView(name, { push = true, scroll = true } = {}) {
|
|
if (!canLeaveCurrentView(name)) return false;
|
|
if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
|
app.currentView = name;
|
|
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
|
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['devices', 'groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
|
if (push) updateBrowserUrl(pathForView(name));
|
|
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
|
|
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
|
if (name === 'logs') loadLogs();
|
|
return true;
|
|
}
|
|
|
|
function showHistoryTab(tab, { push = true, load = true } = {}) {
|
|
const nextTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
|
|
if (app.historyTab === 'energy' && $('#historyHours')) app.historyEnergyHours = $('#historyHours').value || app.historyEnergyHours || '720';
|
|
app.historyTab = nextTab;
|
|
if (nextTab === 'energy' && $('#historyHours')) $('#historyHours').value = app.historyEnergyHours || '720';
|
|
renderHistoryNavigation();
|
|
if (push) updateBrowserUrl(currentHistoryPath());
|
|
if (load) loadHistory();
|
|
}
|
|
|
|
function confirmManualCommandForDisabledZone(id) {
|
|
const zone = disabledZoneForDevice(id);
|
|
if (!zone) return true;
|
|
return confirm(tr('devices.manualDisabledZoneConfirm', { zone: zone.name }));
|
|
}
|
|
|
|
function pingSamples(id) {
|
|
if (!app.pingMonitor.samples[id]) app.pingMonitor.samples[id] = [];
|
|
return app.pingMonitor.samples[id];
|
|
}
|
|
|
|
function addPingSample(id, value, error = '') {
|
|
const samples = pingSamples(id);
|
|
samples.push({ at: Date.now(), value: Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : null, error: String(error || '') });
|
|
if (samples.length > 36) samples.splice(0, samples.length - 36);
|
|
}
|
|
|
|
function pingSparkline(samples) {
|
|
const width = 360, height = 92, padX = 8, padY = 9;
|
|
if (!samples.length) return `<div class="ping-empty">${esc(tr('devices.pingNoSamples'))}</div>`;
|
|
|
|
const valid = samples.map((sample, index) => ({ index, value: sample.value == null ? NaN : Number(sample.value) })).filter(item => Number.isFinite(item.value));
|
|
const values = valid.map(item => item.value);
|
|
const min = values.length ? Math.min(...values) : 0;
|
|
const max = values.length ? Math.max(...values) : 10;
|
|
const ceiling = Math.max(max, 10);
|
|
const floor = Math.min(min, 0);
|
|
const range = Math.max(1, ceiling - floor);
|
|
const lastIndex = Math.max(1, samples.length - 1);
|
|
const pointFor = (index, value) => {
|
|
const x = padX + (index / lastIndex) * (width - padX * 2);
|
|
const y = height - padY - ((value - floor) / range) * (height - padY * 2);
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
};
|
|
|
|
// Keep successful ping runs separate so a packet loss never gets hidden by a line
|
|
// connecting the samples before and after the failed request.
|
|
const runs = [];
|
|
let currentRun = [];
|
|
samples.forEach((sample, index) => {
|
|
const value = sample.value == null ? NaN : Number(sample.value);
|
|
if (Number.isFinite(value)) {
|
|
currentRun.push(pointFor(index, value));
|
|
} else if (currentRun.length) {
|
|
runs.push(currentRun);
|
|
currentRun = [];
|
|
}
|
|
});
|
|
if (currentRun.length) runs.push(currentRun);
|
|
|
|
const lineRuns = runs.map(points => points.length === 1
|
|
? `<circle class="ping-success-point" cx="${points[0].split(',')[0]}" cy="${points[0].split(',')[1]}" r="1.8"></circle>`
|
|
: `<polyline class="ping-success-line" points="${points.join(' ')}"></polyline>`
|
|
).join('');
|
|
|
|
// Failed requests are packet-loss / unavailability samples. Draw a red band and
|
|
// an X at each failed position so even a single loss is immediately visible.
|
|
const losses = samples.map((sample, index) => ({ sample, index })).filter(({ sample }) => sample.value == null).map(({ index }) => {
|
|
const x = padX + (index / lastIndex) * (width - padX * 2);
|
|
const bandWidth = Math.max(4, Math.min(10, (width - padX * 2) / Math.max(samples.length, 12)));
|
|
const left = Math.max(0, x - bandWidth / 2);
|
|
const markerY = height - padY - 5;
|
|
return `<g class="ping-loss-marker"><rect x="${left.toFixed(1)}" y="${padY}" width="${bandWidth.toFixed(1)}" height="${height - padY * 2}" rx="2"></rect><path d="M ${(x - 3).toFixed(1)} ${(markerY - 3).toFixed(1)} L ${(x + 3).toFixed(1)} ${(markerY + 3).toFixed(1)} M ${(x + 3).toFixed(1)} ${(markerY - 3).toFixed(1)} L ${(x - 3).toFixed(1)} ${(markerY + 3).toFixed(1)}"></path></g>`;
|
|
}).join('');
|
|
|
|
const guide = [0.25, 0.5, 0.75].map(ratio => `<line x1="${padX}" y1="${(height * ratio).toFixed(1)}" x2="${width - padX}" y2="${(height * ratio).toFixed(1)}"></line>`).join('');
|
|
return `<svg class="ping-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${esc(tr('devices.pingLive'))}"><g class="ping-grid-lines">${guide}</g>${lineRuns}${losses}</svg>`;
|
|
}
|
|
|
|
function pingStats(samples) {
|
|
const values = samples.map(sample => sample.value == null ? NaN : Number(sample.value)).filter(Number.isFinite);
|
|
if (!values.length) return { current: null, average: null, min: null, max: null };
|
|
const current = [...samples].reverse().find(sample => sample.value != null && Number.isFinite(Number(sample.value)))?.value ?? null;
|
|
return {
|
|
current,
|
|
average: Math.round(values.reduce((sum, value) => sum + value, 0) / values.length),
|
|
min: Math.min(...values),
|
|
max: Math.max(...values),
|
|
};
|
|
}
|
|
|
|
function pingValue(value) {
|
|
return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} ms` : '—';
|
|
}
|
|
|
|
function renderPingDialog() {
|
|
const dialog = $('#pingDialog');
|
|
const select = $('#pingDeviceSelect');
|
|
const all = $('#pingAllDevices');
|
|
const toggle = $('#pingToggleButton');
|
|
const grid = $('#pingLiveGrid');
|
|
if (!dialog || !select || !all || !toggle || !grid) return;
|
|
const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud');
|
|
if (!app.pingMonitor.targetId || !localDevices.some(device => device.id === app.pingMonitor.targetId)) app.pingMonitor.targetId = localDevices[0]?.id || '';
|
|
select.innerHTML = localDevices.map(device => `<option value="${esc(device.id)}" ${device.id === app.pingMonitor.targetId ? 'selected' : ''}>${esc(device.name)}</option>`).join('');
|
|
select.disabled = app.pingMonitor.all;
|
|
all.checked = app.pingMonitor.all;
|
|
toggle.textContent = tr(app.pingMonitor.running ? 'devices.pingStop' : 'devices.pingStart');
|
|
toggle.classList.toggle('primary', app.pingMonitor.running);
|
|
toggle.classList.toggle('secondary', !app.pingMonitor.running);
|
|
const devices = app.pingMonitor.all ? localDevices : localDevices.filter(device => device.id === app.pingMonitor.targetId);
|
|
grid.innerHTML = devices.length ? devices.map(device => {
|
|
const samples = pingSamples(device.id);
|
|
const stats = pingStats(samples);
|
|
const last = samples.length ? samples[samples.length - 1] : null;
|
|
const state = last?.error
|
|
? `<span class="ping-state error">${esc(tr('devices.pingFailed'))}</span>`
|
|
: last?.value != null
|
|
? `<span class="ping-state online">${esc(tr('devices.pingResponding'))}</span>`
|
|
: `<span class="ping-state ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span>`;
|
|
return `<article class="ping-live-card">
|
|
<div class="ping-live-head"><div><strong>${esc(device.name)}</strong><small>${esc(device.ip)}</small></div>${state}</div>
|
|
${pingSparkline(samples)}
|
|
<div class="ping-stat-grid">
|
|
<div><span>${esc(tr('devices.pingCurrent'))}</span><strong>${esc(pingValue(stats.current))}</strong></div>
|
|
<div><span>${esc(tr('devices.pingAverage'))}</span><strong>${esc(pingValue(stats.average))}</strong></div>
|
|
<div><span>${esc(tr('devices.pingMin'))}</span><strong>${esc(pingValue(stats.min))}</strong></div>
|
|
<div><span>${esc(tr('devices.pingMax'))}</span><strong>${esc(pingValue(stats.max))}</strong></div>
|
|
</div>
|
|
<small class="ping-sample-count">${esc(tr('devices.pingSamples'))}: ${samples.length}${last?.error ? ` · ${esc(last.error)}` : ''}</small>
|
|
</article>`;
|
|
}).join('') : `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
|
}
|
|
|
|
function schedulePingCycle(delay = null) {
|
|
clearTimeout(app.pingMonitor.timer);
|
|
app.pingMonitor.timer = null;
|
|
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
|
|
const interval = delay == null ? (app.pingMonitor.all ? 5000 : 3000) : delay;
|
|
app.pingMonitor.timer = setTimeout(runPingCycle, interval);
|
|
}
|
|
|
|
async function runPingCycle() {
|
|
if (!app.pingMonitor.running || !$('#pingDialog')?.open || app.pingMonitor.inFlight) return;
|
|
const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud');
|
|
const targets = app.pingMonitor.all ? [...localDevices] : localDevices.filter(device => device.id === app.pingMonitor.targetId);
|
|
if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; }
|
|
app.pingMonitor.inFlight = true;
|
|
await Promise.allSettled(targets.map(async (device, index) => {
|
|
// Spread all-unit diagnostics and skip a device while explicit manual control is pending.
|
|
// This keeps control traffic higher priority than the live diagnostic chart.
|
|
if (app.pingMonitor.all && index > 0) await new Promise(resolve => setTimeout(resolve, index * 250));
|
|
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
|
|
if (app.deviceControlQueue[device.id] || app.deviceTemperatureDrafts[device.id]) return;
|
|
try {
|
|
const result = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' });
|
|
addPingSample(device.id, result.response_time_ms);
|
|
} catch (error) {
|
|
addPingSample(device.id, null, error.message || String(error));
|
|
}
|
|
}));
|
|
app.pingMonitor.inFlight = false;
|
|
renderPingDialog();
|
|
schedulePingCycle();
|
|
}
|
|
|
|
function startPingMonitor() {
|
|
if (!app.devices.some(device => device.connection_type !== 'gree_cloud')) return;
|
|
app.pingMonitor.running = true;
|
|
renderPingDialog();
|
|
clearTimeout(app.pingMonitor.timer);
|
|
app.pingMonitor.timer = null;
|
|
runPingCycle();
|
|
}
|
|
|
|
function stopPingMonitor() {
|
|
app.pingMonitor.running = false;
|
|
clearTimeout(app.pingMonitor.timer);
|
|
app.pingMonitor.timer = null;
|
|
renderPingDialog();
|
|
}
|
|
|
|
function openDevicePing(id) {
|
|
const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud');
|
|
app.pingMonitor.targetId = (id && localDevices.some(device => device.id === id)) ? id : (localDevices[0]?.id || '');
|
|
app.pingMonitor.all = false;
|
|
openDialog('pingDialog');
|
|
renderPingDialog();
|
|
startPingMonitor();
|
|
}
|
|
|
|
function applyOptimisticCloudCommand(device, command) {
|
|
if (!device || device.connection_type !== 'gree_cloud' || !command || typeof command !== 'object') return;
|
|
const fields = [
|
|
'power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical',
|
|
'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep',
|
|
];
|
|
for (const field of fields) {
|
|
if (Object.prototype.hasOwnProperty.call(command, field) && command[field] !== undefined) {
|
|
device[field] = command[field];
|
|
}
|
|
}
|
|
device.pending_command = true;
|
|
// A stale transport error should not visually override the command the user has just sent.
|
|
// The backend/push path will restore it if the publish actually fails.
|
|
device.last_error = null;
|
|
renderDevices();
|
|
}
|
|
|
|
async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed = false } = {}) {
|
|
const disabledZone = disabledZoneForDevice(id);
|
|
if (disabledZone && !disabledZoneConfirmed && !confirmManualCommandForDisabledZone(id)) return false;
|
|
|
|
const initialDevice = app.devices.find(device => device.id === id);
|
|
const isCloud = initialDevice?.connection_type === 'gree_cloud';
|
|
|
|
// Local keeps the historical synchronous UX exactly as before Cloud support was added.
|
|
if (!isCloud) {
|
|
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
|
const request = previous.catch(() => { }).then(() => {
|
|
const current = app.devices.find(device => device.id === id);
|
|
const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory;
|
|
const body = disabledZone ? { ...command, manual_override: true } : command;
|
|
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
|
|
});
|
|
app.deviceControlQueue[id] = request;
|
|
try {
|
|
const device = await request;
|
|
updateDevice(device); renderAll();
|
|
return true;
|
|
} catch (error) {
|
|
toast(error.message, true);
|
|
try { await loadBootstrap(); } catch (_) { }
|
|
return false;
|
|
} finally {
|
|
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
|
|
}
|
|
}
|
|
|
|
// Cloud controls update immediately in the browser. MQTT ACK/push is confirmation, not
|
|
// a prerequisite for button/temperature feedback. Requests are still serialized per unit.
|
|
const command = typeof commandOrFactory === 'function' ? commandOrFactory(initialDevice) : commandOrFactory;
|
|
if (!command || typeof command !== 'object') return false;
|
|
applyOptimisticCloudCommand(initialDevice, command);
|
|
|
|
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
|
const request = previous.catch(() => { }).then(() => {
|
|
const body = disabledZone ? { ...command, manual_override: true } : command;
|
|
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
|
|
});
|
|
app.deviceControlQueue[id] = request;
|
|
try {
|
|
const device = await request;
|
|
updateDevice(device); renderAll();
|
|
return true;
|
|
} catch (error) {
|
|
toast(error.message, true);
|
|
try { await loadBootstrap(); } catch (_) { }
|
|
return false;
|
|
} finally {
|
|
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
|
|
}
|
|
}
|
|
|
|
function queueDeviceTemperature(id, delta) {
|
|
const device = app.devices.find(item => item.id === id);
|
|
if (!device) return;
|
|
let draft = app.deviceTemperatureDrafts[id];
|
|
const disabledZone = disabledZoneForDevice(id);
|
|
if (disabledZone && !draft?.disabledZoneConfirmed) {
|
|
if (!confirmManualCommandForDisabledZone(id)) return;
|
|
draft = { ...(draft || {}), disabledZoneConfirmed: true };
|
|
}
|
|
const caps = device.capabilities || {};
|
|
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
|
|
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
|
|
const step = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
|
|
const rawNext = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), minTemp, maxTemp);
|
|
const next = Math.round(rawNext / step) * step;
|
|
device.target_temperature = next;
|
|
clearTimeout(draft?.timer);
|
|
draft = {
|
|
...(draft || {}),
|
|
target: next,
|
|
timer: setTimeout(async () => {
|
|
const pending = app.deviceTemperatureDrafts[id];
|
|
if (!pending) return;
|
|
delete app.deviceTemperatureDrafts[id];
|
|
await sendDeviceCommand(id, { target_temperature: pending.target }, { disabledZoneConfirmed: pending.disabledZoneConfirmed === true });
|
|
}, 300),
|
|
};
|
|
app.deviceTemperatureDrafts[id] = draft;
|
|
renderDevices();
|
|
}
|
|
|
|
function enqueueClimateControlTask(task) {
|
|
const previous = app.climateControlQueue || Promise.resolve();
|
|
const request = previous.catch(() => { }).then(task);
|
|
app.climateControlQueue = request;
|
|
request.finally(() => {
|
|
if (app.climateControlQueue === request) app.climateControlQueue = null;
|
|
}).catch(() => { });
|
|
return request;
|
|
}
|
|
|
|
function updateDevice(device) {
|
|
const index = app.devices.findIndex(item => item.id === device.id);
|
|
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
|
|
}
|
|
|
|
function enqueueZoneControlRequest(id, patch) {
|
|
const previous = app.zoneControlQueue[id] || Promise.resolve();
|
|
const request = previous.catch(() => { }).then(() =>
|
|
api(`/api/zones/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch })
|
|
);
|
|
app.zoneControlQueue[id] = request;
|
|
request.finally(() => {
|
|
if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id];
|
|
}).catch(() => { });
|
|
return request;
|
|
}
|
|
|
|
async function sendZoneLocalPower(id, power) {
|
|
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
|
app.zoneControlSeq[id] = sequence;
|
|
try {
|
|
const zone = await enqueueZoneControlRequest(id, { power });
|
|
if (app.zoneControlSeq[id] !== sequence) return;
|
|
const index = app.zones.findIndex(item => item.id === zone.id);
|
|
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
|
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
|
|
} catch (error) {
|
|
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
|
}
|
|
}
|
|
|
|
async function sendZoneControl(id, patch) {
|
|
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
|
app.zoneControlSeq[id] = sequence;
|
|
try {
|
|
const zone = await enqueueZoneControlRequest(id, patch);
|
|
if (app.zoneControlSeq[id] !== sequence) return;
|
|
const index = app.zones.findIndex(item => item.id === zone.id);
|
|
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
|
renderSummary(); renderZones(); scheduleControlPlanLoad();
|
|
} catch (error) {
|
|
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function cancelCompressorTask(id) {
|
|
try {
|
|
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, { method: 'POST' });
|
|
if (result.zone) {
|
|
const index = app.zones.findIndex(item => item.id === result.zone.id);
|
|
if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone);
|
|
}
|
|
renderAll(); scheduleControlPlanLoad();
|
|
toast(tr(result.cancelled ? 'zones.queuedCancelled' : 'zones.noQueuedTask'));
|
|
} catch (error) { toast(error.message, true); }
|
|
}
|
|
|
|
async function cancelAllCompressorTasks() {
|
|
try {
|
|
const result = await api('/api/compressor-queue/cancel-all', { method: 'POST' });
|
|
(result.zones || []).forEach(zone => {
|
|
const index = app.zones.findIndex(item => item.id === zone.id);
|
|
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
|
});
|
|
renderAll(); scheduleControlPlanLoad();
|
|
toast(tr('zones.queuedCancelledAll', { count: Number(result.cancelled || 0) }));
|
|
} catch (error) { toast(error.message, true); }
|
|
}
|
|
|
|
function queueZoneTemperature(zone, value, { snapToHalf = true } = {}) {
|
|
const clamped = clamp(value, 8, 30);
|
|
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
|
|
zone.manual_setpoint = next;
|
|
zone.setpoint = next;
|
|
zone.effective_setpoint = next;
|
|
renderZones();
|
|
clearTimeout(app.zoneTemperatureTimers[zone.id]);
|
|
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, { setpoint: next }), 160);
|
|
}
|
|
|
|
function beginInlineTemperatureEdit(target) {
|
|
if (!target || target.querySelector('input') || target.dataset.editable === 'false') return;
|
|
const kind = target.dataset.temperatureKind;
|
|
const id = target.dataset.id;
|
|
if (kind === 'device' && app.deviceTemperatureDrafts[id]) {
|
|
clearTimeout(app.deviceTemperatureDrafts[id].timer);
|
|
delete app.deviceTemperatureDrafts[id];
|
|
}
|
|
const value = parseDecimal(target.dataset.value);
|
|
if (!Number.isFinite(value) || !kind || !id) return;
|
|
const step = kind === 'device' ? Number(target.dataset.tempStep || 1) : 0.5;
|
|
const minValue = kind === 'device' ? Number(target.dataset.tempMin || 8) : 8;
|
|
const maxValue = kind === 'device' ? Number(target.dataset.tempMax || 30) : 30;
|
|
const decimals = kind === 'zone' || step < 1 ? 1 : 0;
|
|
const input = document.createElement('input');
|
|
input.className = 'inline-temperature-input';
|
|
input.type = 'text';
|
|
input.inputMode = 'decimal';
|
|
input.value = value.toFixed(decimals);
|
|
input.setAttribute('aria-label', tr('common.targetTemperature'));
|
|
input.title = tr('common.targetTemperature');
|
|
target.classList.add('editing');
|
|
target.replaceChildren(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
let finished = false;
|
|
const finish = async commit => {
|
|
if (finished) return;
|
|
finished = true;
|
|
const parsed = parseDecimal(input.value);
|
|
if (!commit || !Number.isFinite(parsed) || parsed < minValue || parsed > maxValue) {
|
|
if (commit && (!Number.isFinite(parsed) || parsed < minValue || parsed > maxValue)) toast(tr('validation.range', { min: minValue, max: maxValue }), true);
|
|
renderAll();
|
|
return;
|
|
}
|
|
if (kind === 'zone') {
|
|
const zone = app.zones.find(item => item.id === id);
|
|
if (zone) queueZoneTemperature(zone, parsed, { snapToHalf: false });
|
|
return;
|
|
}
|
|
const device = app.devices.find(item => item.id === id);
|
|
if (device) {
|
|
const snapped = Math.round(clamp(parsed, minValue, maxValue) / step) * step;
|
|
await sendDeviceCommand(id, { target_temperature: snapped });
|
|
}
|
|
};
|
|
input.addEventListener('keydown', event => {
|
|
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
|
|
if (event.key === 'Escape') { event.preventDefault(); finished = true; renderAll(); }
|
|
});
|
|
input.addEventListener('blur', () => finish(true), { once: true });
|
|
}
|
|
|
|
function showDiscoveryNames(devices) {
|
|
const items = Array.isArray(devices) ? devices : [];
|
|
app.localDiscoveryCandidates = items.map(item => ({ ...item }));
|
|
const list = $('#discoveryNamesList');
|
|
if (!list) return;
|
|
list.innerHTML = items.length ? items.map((device, index) => {
|
|
const alreadyAdded = device.already_added === true;
|
|
const protocolVersion = Number(device.protocol_version);
|
|
const protocol = protocolVersion === 2 ? 'V2 AES-GCM' : protocolVersion === 1 ? 'V1 AES-ECB' : tr('devices.protocolAuto');
|
|
const selectorId = `local-discovery-${index}`;
|
|
return `<div class="discovery-name-row local-discovery-row">
|
|
<div class="local-discovery-head">
|
|
<input type="checkbox" id="${esc(selectorId)}" data-discovery-select data-discovery-index="${index}" ${alreadyAdded ? 'disabled' : 'checked'}>
|
|
<label for="${esc(selectorId)}">
|
|
<strong>${esc(device.model || device.name || 'GREE')}</strong>
|
|
<small>IP ${esc(device.ip || '—')} · MAC ${esc(device.mac || '—')} · ${esc(protocol)}</small>
|
|
</label>
|
|
${alreadyAdded ? `<span class="badge">${esc(tr('discovery.alreadyAdded'))}</span>` : ''}
|
|
</div>
|
|
<input data-discovery-name="${index}" maxlength="80" value="${esc(device.name || device.model || 'GREE')}" ${alreadyAdded ? 'disabled' : ''} aria-label="${esc(tr('common.name'))}">
|
|
</div>`;
|
|
}).join('') : `<div class="empty"><strong>${esc(tr('discovery.empty'))}</strong><span>${esc(tr('discovery.emptyHint'))}</span></div>`;
|
|
openDialog('discoveryNamesDialog');
|
|
}
|
|
|
|
function populateDeviceRename(id) {
|
|
const device = app.devices.find(item => item.id === id); if (!device) return;
|
|
const form = $('#renameDeviceForm'); form.reset();
|
|
const isCloud = device.connection_type === 'gree_cloud';
|
|
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip || ''; form.port.value = String(device.port || 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
|
|
form.ip.disabled = isCloud; form.port.disabled = isCloud; form.protocol_version.disabled = isCloud;
|
|
const result = $('#deviceConfigCheckResult');
|
|
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
|
|
openDialog('renameDeviceDialog');
|
|
}
|
|
|
|
async function deleteEntity(type, id, labelKey) {
|
|
if (!confirm(tr('confirm.delete', { label: tr(labelKey) }))) return;
|
|
try { await api(`/api/${type}/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadBootstrap(); toast(tr('common.removed')); }
|
|
catch (error) { toast(error.message, true); }
|
|
}
|
|
|
|
function openDialog(id) {
|
|
fillSelects();
|
|
const dialog = document.getElementById(id);
|
|
if (dialog && !dialog.open) {
|
|
const form = $('form', dialog);
|
|
if (form) { clearFormErrors(form); markFormClean(form); }
|
|
dialog.showModal();
|
|
}
|
|
}
|
|
|
|
function updateZoneHysteresisFields({ syncFromCommon = false } = {}) {
|
|
const form = $('#zoneForm');
|
|
if (!form) return;
|
|
const separate = !!form.separate_hysteresis?.checked;
|
|
$('#commonHysteresisField').hidden = separate;
|
|
$('#separateHysteresisFields').hidden = !separate;
|
|
if (separate && syncFromCommon && form.dataset.separateHysteresisInitialized !== 'true') {
|
|
const common = parseDecimal(form.hysteresis.value);
|
|
if (Number.isFinite(common)) {
|
|
form.cool_hysteresis.value = String(common);
|
|
form.heat_hysteresis.value = String(common);
|
|
}
|
|
form.dataset.separateHysteresisInitialized = 'true';
|
|
}
|
|
}
|
|
|
|
function updateZoneSensorFields() {
|
|
const form = $('#zoneForm');
|
|
if (!form) return;
|
|
const external = form.sensor_source.value !== 'device';
|
|
$('#externalSensorFields').hidden = !external;
|
|
form.ha_entity_id.required = external;
|
|
}
|
|
|
|
function updateSchedulePresetField() {
|
|
const form = $('#scheduleForm'); if (!form) return;
|
|
$('#scheduleSetpointField').hidden = form.preset.value !== 'custom';
|
|
form.setpoint.required = form.preset.value === 'custom';
|
|
}
|
|
|
|
function updateAutomationTargetFields() {
|
|
const form = $('#automationForm'); if (!form) return;
|
|
const groupTarget = form.action_target_kind.value === 'group';
|
|
$('#automationDeviceTarget').hidden = groupTarget;
|
|
$('#automationGroupTarget').hidden = !groupTarget;
|
|
$('#automationGroupPreset').hidden = !groupTarget;
|
|
$('#automationTargetTemperature').hidden = groupTarget;
|
|
$('#automationDeviceSwingOptions').hidden = groupTarget;
|
|
$('#automationDeviceFeatureOptions').hidden = groupTarget;
|
|
$('#automationGroupHint').hidden = !groupTarget;
|
|
form.action_device_id.required = !groupTarget;
|
|
form.action_group_id.required = groupTarget;
|
|
form.action_target_temperature.disabled = groupTarget;
|
|
form.action_swing_vertical.disabled = groupTarget;
|
|
form.action_swing_horizontal.disabled = groupTarget;
|
|
['action_fan_speed', 'action_quiet', 'action_turbo', 'action_light', 'action_air', 'action_xfan', 'action_health', 'action_sleep'].forEach(name => { if (form.elements[name]) form.elements[name].disabled = groupTarget; });
|
|
if (groupTarget) {
|
|
form.action_target_temperature.value = '';
|
|
form.action_swing_vertical.value = '';
|
|
form.action_swing_horizontal.value = '';
|
|
['action_fan_speed', 'action_quiet', 'action_turbo', 'action_light', 'action_air', 'action_xfan', 'action_health', 'action_sleep'].forEach(name => { if (form.elements[name]) form.elements[name].value = ''; });
|
|
}
|
|
[...form.action_mode.options].forEach(option => {
|
|
if (!['dry', 'fan'].includes(option.value)) return;
|
|
option.disabled = groupTarget;
|
|
option.hidden = groupTarget;
|
|
});
|
|
if (groupTarget && ['dry', 'fan'].includes(form.action_mode.value)) form.action_mode.value = '';
|
|
}
|
|
|
|
function populateZone(id) {
|
|
const item = app.zones.find(v => v.id === id); if (!item) return;
|
|
const form = $('#zoneForm');
|
|
form.reset();
|
|
form.dataset.editingZoneId = item.id;
|
|
form.elements.id.value = item.id;
|
|
fillSelects();
|
|
Object.entries(item).forEach(([key, value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
|
|
form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool');
|
|
if (Number(item.profile_version || 0) === 0) {
|
|
if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21);
|
|
else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23);
|
|
}
|
|
form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100);
|
|
form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3);
|
|
form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120);
|
|
form.standby_offset_c.value = Number(item.standby_offset_c ?? 2);
|
|
form.smart_fan.checked = item.smart_fan !== false;
|
|
form.separate_hysteresis.checked = item.separate_hysteresis === true;
|
|
const commonHysteresis = Number(item.hysteresis ?? 0.6);
|
|
form.cool_hysteresis.value = Number(item.separate_hysteresis ? (item.cool_hysteresis ?? commonHysteresis) : commonHysteresis);
|
|
form.heat_hysteresis.value = Number(item.separate_hysteresis ? (item.heat_hysteresis ?? commonHysteresis) : commonHysteresis);
|
|
form.dataset.separateHysteresisInitialized = item.separate_hysteresis === true ? 'true' : 'false';
|
|
form.enabled.checked = item.enabled; updateZoneSensorFields(); updateZoneHysteresisFields(); openDialog('zoneDialog');
|
|
}
|
|
|
|
const dateTimeLocalValue = value => {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
if (!Number.isFinite(date.getTime())) return '';
|
|
const pad = number => String(number).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
};
|
|
|
|
function updateTemporaryThermostatFields() {
|
|
const form = $('#temporaryThermostatForm'); if (!form) return;
|
|
const startKind = form.start_kind.value;
|
|
const kind = form.finish_kind.value;
|
|
const startDelay = $('#temporaryStartDelayFields');
|
|
const startAt = $('#temporaryStartAtFields');
|
|
const duration = $('#temporaryDurationFields');
|
|
const until = $('#temporaryUntilFields');
|
|
const temperature = $('#temporaryTemperatureFields');
|
|
const schedule = $('#temporaryScheduleFields');
|
|
startDelay.hidden = startKind !== 'delay';
|
|
startAt.hidden = startKind !== 'at';
|
|
duration.hidden = kind !== 'duration';
|
|
until.hidden = kind !== 'until';
|
|
temperature.hidden = !['temperature_reached', 'temperature_stable'].includes(kind);
|
|
schedule.hidden = kind !== 'schedule_boundary';
|
|
$('#temporaryHoldField').hidden = kind !== 'temperature_stable';
|
|
form.start_delay_minutes.required = startKind === 'delay';
|
|
form.start_at.required = startKind === 'at';
|
|
form.duration_minutes.required = kind === 'duration';
|
|
form.until.required = kind === 'until';
|
|
form.hold_minutes.required = kind === 'temperature_stable';
|
|
}
|
|
|
|
function populateTemporaryThermostat(id) {
|
|
const zone = app.zones.find(item => item.id === id); if (!zone) return;
|
|
const form = $('#temporaryThermostatForm');
|
|
form.reset();
|
|
form.elements.zone_id.value = zone.id;
|
|
form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1);
|
|
form.start_kind.value = 'now';
|
|
form.start_delay_minutes.value = '30';
|
|
form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000));
|
|
form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000));
|
|
const session = zone.temporary_quick_thermostat;
|
|
const activeSession = !!session?.activated_at;
|
|
form.dataset.activeSession = activeSession ? 'true' : 'false';
|
|
if (session) {
|
|
form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now');
|
|
if (form.start_kind.value === 'delay' && session.started_at) {
|
|
form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000));
|
|
}
|
|
if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at);
|
|
form.finish_kind.value = session.finish_kind || 'duration';
|
|
if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1);
|
|
if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator;
|
|
form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1);
|
|
if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60));
|
|
if (session.finish_kind === 'duration') {
|
|
if (session.duration_seconds) {
|
|
form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60));
|
|
} else if (session.expires_at) {
|
|
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
|
|
form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000));
|
|
}
|
|
}
|
|
if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at);
|
|
if (session.safety_duration_seconds) {
|
|
form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60));
|
|
} else if (session.safety_expires_at) {
|
|
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
|
|
form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000));
|
|
} else if (['temperature_reached', 'temperature_stable'].includes(session.finish_kind)) {
|
|
form.max_duration_minutes.value = '';
|
|
}
|
|
}
|
|
|
|
form.start_kind.disabled = activeSession;
|
|
form.start_delay_minutes.disabled = activeSession;
|
|
form.start_at.disabled = activeSession;
|
|
$$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; });
|
|
|
|
updateTemporaryThermostatFields();
|
|
const active = $('#temporaryThermostatActive');
|
|
active.hidden = !session;
|
|
$('#temporaryThermostatStop').dataset.id = zone.id;
|
|
$('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart');
|
|
updateTemporaryThermostatCountdowns();
|
|
openDialog('temporaryThermostatDialog');
|
|
updateTemporaryThermostatCountdowns();
|
|
}
|
|
|
|
function populateSchedule(id) {
|
|
const item = app.schedules.find(v => v.id === id); if (!item) return;
|
|
const form = $('#scheduleForm'); form.reset(); fillSelects();
|
|
['id', 'name', 'zone_id', 'start_time', 'end_time', 'setpoint', 'preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
|
form.enabled.checked = item.enabled;
|
|
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
|
|
updateSchedulePresetField(); openDialog('scheduleDialog');
|
|
}
|
|
|
|
function populateAutomation(id) {
|
|
const item = app.automations.find(v => v.id === id); if (!item) return;
|
|
const form = $('#automationForm'); form.reset(); fillSelects();
|
|
['id', 'name', 'trigger_kind', 'trigger_device_id', 'threshold', 'at_time', 'action_device_id', 'cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
|
const groupTarget = !!item.action_group_id;
|
|
form.action_target_kind.value = groupTarget ? 'group' : 'device';
|
|
if (groupTarget) form.action_group_id.value = item.action_group_id;
|
|
form.action_power.value = item.action.power == null ? '' : String(item.action.power);
|
|
form.action_mode.value = item.action.mode || '';
|
|
form.action_preset.value = item.action_preset || '';
|
|
form.action_target_temperature.value = item.action.target_temperature ?? '';
|
|
form.action_swing_vertical.value = item.action.swing_vertical == null ? '' : String(item.action.swing_vertical);
|
|
form.action_swing_horizontal.value = item.action.swing_horizontal == null ? '' : String(item.action.swing_horizontal);
|
|
form.action_fan_speed.value = item.action.fan_speed == null ? '' : String(item.action.fan_speed);
|
|
['quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].forEach(key => { const field = form.elements[`action_${key}`]; if (field) field.value = item.action[key] == null ? '' : String(item.action[key]); });
|
|
form.enabled.checked = item.enabled;
|
|
updateAutomationTargetFields();
|
|
openDialog('automationDialog');
|
|
}
|
|
|
|
|
|
async function openDeviceDetails(id) {
|
|
const device = app.devices.find(item => item.id === id); if (!device) return;
|
|
const form = $('#deviceDetailsForm'); if (!form) return;
|
|
form.reset();
|
|
form.elements.id.value = device.id;
|
|
form.name.value = device.name || '';
|
|
form.energy_source.value = device.energy_source || 'auto';
|
|
const title = $('#deviceDetailsTitle');
|
|
if (title) title.textContent = device.connection_type === 'gree_cloud' ? tr('devices.cloudDetails') : tr('energy.title');
|
|
const meta = $('#deviceDetailsMeta');
|
|
if (meta) meta.innerHTML = device.connection_type === 'gree_cloud'
|
|
? `<div><span>${esc(tr('devices.connection'))}</span><strong>GREE Cloud</strong></div><div><span>${esc(tr('devices.transport'))}</span><strong>MQTT / TLS</strong></div><div><span>${esc(tr('devices.cloudDeviceId'))}</span><strong>${esc(device.cloud_device_id || device.mac || '—')}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`
|
|
: `<div><span>${esc(tr('devices.connection'))}</span><strong>Local</strong></div><div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip || '—')}:${esc(device.port || 7000)}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`;
|
|
|
|
const installation = deviceInstallationForDevice(device.id);
|
|
const groupNote = $('#deviceEnergyGroupNote');
|
|
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
|
|
if (groupNote) {
|
|
groupNote.hidden = !groupOwnsEnergy;
|
|
groupNote.innerHTML = groupOwnsEnergy
|
|
? `<span>${esc(deviceInstallationKindLabel(installation))}</span><strong>${esc(tr('devices.groupedEnergyNote', { name: installation.name, source: installationEnergySourceLabel(installation) }))}</strong>`
|
|
: '';
|
|
}
|
|
|
|
const source = form.energy_source;
|
|
if (groupOwnsEnergy) source.value = installation.energy_source || 'auto';
|
|
source.disabled = groupOwnsEnergy;
|
|
const cloudOption = [...source.options].find(option => option.value === 'gree_cloud');
|
|
if (cloudOption) cloudOption.disabled = device.capabilities?.energy_meter !== true;
|
|
const sensorSelect = form.ha_energy_entity_id;
|
|
sensorSelect.disabled = groupOwnsEnergy;
|
|
sensorSelect.innerHTML = '<option value="">—</option>';
|
|
const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured);
|
|
if (haConfigured) {
|
|
try {
|
|
const response = await api('/api/integrations/home-assistant/energy-sensors');
|
|
for (const sensor of (response.sensors || [])) {
|
|
const option = document.createElement('option');
|
|
option.value = sensor.entity_id;
|
|
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
|
|
option.dataset.unit = sensor.unit || '';
|
|
option.dataset.deviceClass = sensor.device_class || '';
|
|
option.dataset.stateClass = sensor.state_class || '';
|
|
sensorSelect.append(option);
|
|
}
|
|
} catch (_) { }
|
|
}
|
|
const energyEntity = groupOwnsEnergy ? installation.ha_energy_entity_id : device.ha_energy_entity_id;
|
|
const energyUnit = groupOwnsEnergy ? installation.ha_energy_unit : device.ha_energy_unit;
|
|
const energyDeviceClass = groupOwnsEnergy ? installation.ha_energy_device_class : device.ha_energy_device_class;
|
|
const energyStateClass = groupOwnsEnergy ? installation.ha_energy_state_class : device.ha_energy_state_class;
|
|
if (energyEntity && ![...sensorSelect.options].some(option => option.value === energyEntity)) {
|
|
const option = document.createElement('option');
|
|
option.value = energyEntity;
|
|
option.textContent = energyEntity;
|
|
option.dataset.unit = energyUnit || '';
|
|
option.dataset.deviceClass = energyDeviceClass || '';
|
|
option.dataset.stateClass = energyStateClass || '';
|
|
sensorSelect.append(option);
|
|
}
|
|
sensorSelect.value = energyEntity || '';
|
|
updateDeviceEnergySensorMeta();
|
|
openDialog('deviceDetailsDialog');
|
|
}
|
|
|
|
function renderDeviceGroupsDialogList() {
|
|
const host = $('#deviceGroupsList'); if (!host) return;
|
|
const groups = app.deviceGroups || [];
|
|
host.innerHTML = groups.length ? groups.map(group => {
|
|
const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean);
|
|
return `<div class="installation-list-row"><button type="button" data-action="edit-device-group" data-id="${esc(group.id)}"><strong>${esc(group.name)}</strong><small>${esc(deviceInstallationKindLabel(group))} · ${esc(members.join(' · ') || '—')} · ${esc(installationEnergySourceLabel(group))}</small></button><div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-action="delete-device-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>`;
|
|
}).join('') : `<div class="empty"><strong>${esc(tr('devices.newInstallation'))}</strong><span>${esc(tr('devices.installationsHint'))}</span></div>`;
|
|
}
|
|
|
|
function selectedDeviceGroupIds() {
|
|
return $$('#deviceGroupDeviceChoices input[type="checkbox"]:checked').map(input => input.value);
|
|
}
|
|
|
|
function syncDeviceGroupMemberSelects() {
|
|
const form = $('#deviceGroupForm'); if (!form) return;
|
|
const ids = selectedDeviceGroupIds();
|
|
const energy = form.energy_device_id;
|
|
const outdoor = form.outdoor_temperature_device_id;
|
|
const oldEnergy = energy.value, oldOutdoor = outdoor.value;
|
|
energy.innerHTML = '<option value="">—</option>' + ids.map(id => {
|
|
const device = app.devices.find(item => item.id === id);
|
|
if (!device) return '';
|
|
const supported = device.connection_type === 'gree_cloud' && device.capabilities?.energy_meter === true;
|
|
return `<option value="${esc(id)}" ${supported ? '' : 'disabled'}>${esc(device.name)}${supported ? '' : ' · —'}</option>`;
|
|
}).join('');
|
|
outdoor.innerHTML = `<option value="">${esc(tr('devices.noSharedOutdoor'))}</option>` + ids.map(id => {
|
|
const device = app.devices.find(item => item.id === id);
|
|
return device ? `<option value="${esc(id)}">${esc(device.name)}</option>` : '';
|
|
}).join('');
|
|
if ([...energy.options].some(option => option.value === oldEnergy && !option.disabled)) energy.value = oldEnergy;
|
|
if ([...outdoor.options].some(option => option.value === oldOutdoor)) outdoor.value = oldOutdoor;
|
|
}
|
|
|
|
function renderDeviceGroupDeviceChoices(selectedIds = []) {
|
|
const form = $('#deviceGroupForm');
|
|
const host = $('#deviceGroupDeviceChoices'); if (!form || !host) return;
|
|
const currentId = form.elements.id.value;
|
|
const occupied = new Map();
|
|
for (const group of (app.deviceGroups || [])) {
|
|
if (group.id === currentId) continue;
|
|
for (const id of (group.device_ids || [])) occupied.set(id, group.name);
|
|
}
|
|
host.innerHTML = app.devices.map(device => {
|
|
const owner = occupied.get(device.id);
|
|
const checked = selectedIds.includes(device.id);
|
|
return `<label class="check ${owner ? 'disabled' : ''}" title="${owner ? esc(owner) : ''}"><input type="checkbox" name="device_ids" value="${esc(device.id)}" ${checked ? 'checked' : ''} ${owner ? 'disabled' : ''}> <span>${esc(device.name)}${owner ? ` · ${esc(owner)}` : ''}</span></label>`;
|
|
}).join('');
|
|
syncDeviceGroupMemberSelects();
|
|
}
|
|
|
|
async function loadDeviceGroupEnergySensors(group = null) {
|
|
const form = $('#deviceGroupForm'); if (!form) return;
|
|
const select = form.ha_energy_entity_id;
|
|
select.innerHTML = '<option value="">—</option>';
|
|
const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured);
|
|
if (haConfigured) {
|
|
try {
|
|
const response = await api('/api/integrations/home-assistant/energy-sensors');
|
|
for (const sensor of (response.sensors || [])) {
|
|
const option = document.createElement('option');
|
|
option.value = sensor.entity_id;
|
|
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
|
|
option.dataset.unit = sensor.unit || '';
|
|
option.dataset.deviceClass = sensor.device_class || '';
|
|
option.dataset.stateClass = sensor.state_class || '';
|
|
select.append(option);
|
|
}
|
|
} catch (_) { }
|
|
}
|
|
if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) {
|
|
const option = document.createElement('option');
|
|
option.value = group.ha_energy_entity_id;
|
|
option.textContent = group.ha_energy_entity_id;
|
|
option.dataset.unit = group.ha_energy_unit || '';
|
|
option.dataset.deviceClass = group.ha_energy_device_class || '';
|
|
option.dataset.stateClass = group.ha_energy_state_class || '';
|
|
select.append(option);
|
|
}
|
|
select.value = group?.ha_energy_entity_id || '';
|
|
updateDeviceGroupEnergySensorMeta();
|
|
}
|
|
|
|
async function populateDeviceGroupForm(group = null) {
|
|
const form = $('#deviceGroupForm'); if (!form) return;
|
|
form.reset();
|
|
form.elements.id.value = group?.id || '';
|
|
form.name.value = group?.name || '';
|
|
form.kind.value = group?.kind || 'split';
|
|
form.energy_source.value = group?.energy_source || 'auto';
|
|
renderDeviceGroupDeviceChoices(group?.device_ids || []);
|
|
syncDeviceGroupMemberSelects();
|
|
form.energy_device_id.value = group?.energy_device_id || '';
|
|
form.outdoor_temperature_device_id.value = group?.outdoor_temperature_device_id || '';
|
|
await loadDeviceGroupEnergySensors(group);
|
|
}
|
|
|
|
async function openDeviceGroupsDialog(groupId = '') {
|
|
renderDeviceGroupsDialogList();
|
|
const group = groupId ? (app.deviceGroups || []).find(item => item.id === groupId) : null;
|
|
await populateDeviceGroupForm(group || null);
|
|
openDialog('deviceGroupsDialog');
|
|
}
|
|
|
|
function updateDeviceGroupEnergySensorMeta() {
|
|
const select = $('#deviceGroupForm')?.ha_energy_entity_id;
|
|
const meta = $('#deviceGroupEnergySensorMeta');
|
|
if (!select || !meta) return;
|
|
const option = select.selectedOptions?.[0];
|
|
meta.textContent = option?.value
|
|
? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}`
|
|
: tr('energy.noHaSensor');
|
|
}
|
|
|
|
function updateDeviceEnergySensorMeta() {
|
|
const select = $('#deviceDetailsForm')?.ha_energy_entity_id;
|
|
const meta = $('#deviceEnergySensorMeta');
|
|
if (!select || !meta) return;
|
|
const option = select.selectedOptions?.[0];
|
|
meta.textContent = option?.value
|
|
? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}`
|
|
: tr('energy.noHaSensor');
|
|
}
|
|
|
|
async function openCloudDiagnostics(id) {
|
|
const dialog = $('#cloudDiagnosticsDialog');
|
|
const payload = $('#cloudDiagnosticsPayload');
|
|
if (!dialog || !payload) return;
|
|
dialog.dataset.deviceId = id;
|
|
payload.textContent = tr('common.loading');
|
|
openDialog('cloudDiagnosticsDialog');
|
|
try {
|
|
const diagnostics = await api(`/api/devices/${encodeURIComponent(id)}/cloud-diagnostics`);
|
|
payload.textContent = JSON.stringify(diagnostics, null, 2);
|
|
} catch (error) {
|
|
payload.textContent = error.message || String(error);
|
|
}
|
|
}
|