v0.14.1
This commit is contained in:
Vendored
+1
@@ -34,6 +34,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
|
||||
|
||||
function applyBootstrapSnapshot(data) {
|
||||
app.devices = data.devices || [];
|
||||
app.deviceGroups = data.device_groups || [];
|
||||
app.zones = data.zones || [];
|
||||
app.groups = data.groups || [];
|
||||
app.schedules = data.schedules || [];
|
||||
|
||||
+81
-30
@@ -25,6 +25,32 @@ function historyEntityOptions() {
|
||||
return { zones, devices, sensors, entities };
|
||||
}
|
||||
|
||||
function energyHistoryTargets() {
|
||||
const configuredGroups = (app.deviceGroups || []).filter(group => !!group.energy_device_id || !!group.ha_energy_entity_id);
|
||||
const groups = configuredGroups.map(group => ({
|
||||
id: `group:${group.id}`,
|
||||
label: `${group.name} · ${deviceInstallationKindLabel(group)}`,
|
||||
type: 'group',
|
||||
group,
|
||||
}));
|
||||
const groupedIds = new Set(configuredGroups.flatMap(group => group.device_ids || []));
|
||||
const devices = app.devices
|
||||
.filter(device => (device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id) && !groupedIds.has(device.id))
|
||||
.map(device => ({ id: device.id, label: `${device.name} · ${deviceTransportLabel(device)}`, type: 'device', device }));
|
||||
return [...groups, ...devices];
|
||||
}
|
||||
|
||||
function normalizeEnergyHistoryTargets() {
|
||||
const available = energyHistoryTargets();
|
||||
const ids = new Set(available.map(item => item.id));
|
||||
app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id));
|
||||
if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice];
|
||||
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id];
|
||||
app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8);
|
||||
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
|
||||
return available;
|
||||
}
|
||||
|
||||
function renderHistoryNavigation() {
|
||||
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
|
||||
const host = $('#historyContextControls'); if (!host) return;
|
||||
@@ -36,14 +62,15 @@ function renderHistoryNavigation() {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
|
||||
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
|
||||
} else if (app.historyTab === 'energy') {
|
||||
const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id);
|
||||
if (!app.historyEnergyDevice && energyDevices.length) app.historyEnergyDevice = energyDevices[0].id;
|
||||
const deviceOptions = energyDevices.map(device => `<option value="${esc(device.id)}">${esc(device.name)} · ${esc(deviceTransportLabel(device))}</option>`).join('');
|
||||
host.innerHTML = energyDevices.length
|
||||
? `<label><span>${esc(tr('common.device'))}</span><select id="historyEnergyDeviceSelect">${deviceOptions}</select></label><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label>`
|
||||
const targets = normalizeEnergyHistoryTargets();
|
||||
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.label)}</option>`).join('');
|
||||
host.innerHTML = targets.length
|
||||
? `<label class="history-energy-targets"><span>${esc(tr('energy.targets'))}</span><select id="historyEnergyTargetSelect" multiple size="${Math.min(6, Math.max(2, targets.length))}">${targetOptions}</select><small>${esc(tr('energy.multiselectHint'))}</small></label><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
|
||||
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`;
|
||||
const deviceSelect = $('#historyEnergyDeviceSelect'); if (deviceSelect && [...deviceSelect.options].some(option => option.value === app.historyEnergyDevice)) deviceSelect.value = app.historyEnergyDevice;
|
||||
const targetSelect = $('#historyEnergyTargetSelect');
|
||||
if (targetSelect) [...targetSelect.options].forEach(option => { option.selected = app.historyEnergyTargets.includes(option.value); });
|
||||
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
|
||||
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
|
||||
} else if (app.historyTab === 'sensors') {
|
||||
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
|
||||
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
|
||||
@@ -55,7 +82,7 @@ function renderHistoryNavigation() {
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (app.historyLoading) return;
|
||||
if (app.historyLoading) { app.historyReloadPending = true; return; }
|
||||
app.historyLoading = true;
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
try {
|
||||
@@ -75,7 +102,10 @@ async function loadHistory() {
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
renderHistoryPage();
|
||||
} finally { app.historyLoading = false; }
|
||||
} finally {
|
||||
app.historyLoading = false;
|
||||
if (app.historyReloadPending) { app.historyReloadPending = false; setTimeout(() => loadHistory(), 0); }
|
||||
}
|
||||
}
|
||||
|
||||
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
|
||||
@@ -116,7 +146,7 @@ function redrawHistoryChart(id) {
|
||||
if (!runtime || !canvas) return;
|
||||
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
|
||||
if (runtime.kind === 'energy') {
|
||||
drawEnergyBarChart(canvas, runtime.buckets, runtime.options);
|
||||
drawEnergyBarChart(canvas, runtime.series, runtime.options);
|
||||
} else {
|
||||
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
|
||||
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
|
||||
@@ -438,24 +468,42 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma
|
||||
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels });
|
||||
}
|
||||
|
||||
function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
|
||||
function energyBucketLabel(timestamp, interval) {
|
||||
const date = new Date(timestamp);
|
||||
if (interval === 'hourly') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'short', year: '2-digit' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
|
||||
}
|
||||
|
||||
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
|
||||
if (!canvas) return;
|
||||
const options = { height };
|
||||
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', buckets, options });
|
||||
if (!buckets?.length) return drawEmptyChart(canvas, height);
|
||||
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
|
||||
? seriesInput
|
||||
: [{ key: 'energy', label: 'kWh', color: historySeriesColor(0), buckets: Array.isArray(seriesInput) ? seriesInput : [] }];
|
||||
const options = { height, interval };
|
||||
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', series, options });
|
||||
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
|
||||
const starts = [...new Set(visibleSeries.flatMap(item => (item.buckets || []).map(row => row.start)))].sort((a, b) => new Date(a) - new Date(b));
|
||||
if (!starts.length || !visibleSeries.length) {
|
||||
drawEmptyChart(canvas, height);
|
||||
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
|
||||
updateChartZoomControls(canvas.id);
|
||||
return;
|
||||
}
|
||||
const maps = visibleSeries.map(item => new Map((item.buckets || []).map(row => [row.start, Math.max(0, Number(row.consumption_kwh) || 0)])));
|
||||
const values = maps.flatMap(map => starts.map(start => map.get(start) || 0));
|
||||
const prepared = prepareCanvas(canvas, height);
|
||||
const { ctx, width } = prepared;
|
||||
height = prepared.height;
|
||||
const text = cssColor('--muted', '#888');
|
||||
const grid = cssColor('--grid', '#333');
|
||||
const fill = cssColor('--accent', '#3ecf8e');
|
||||
const pad = { left: 58, right: 18, top: 20, bottom: 48 };
|
||||
const values = buckets.map(row => Math.max(0, Number(row.consumption_kwh) || 0));
|
||||
const max = Math.max(0.1, ...values) * 1.1;
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
const slot = plotW / Math.max(1, buckets.length);
|
||||
const barW = Math.max(2, Math.min(slot * 0.72, 42));
|
||||
const slot = plotW / Math.max(1, starts.length);
|
||||
const groupW = Math.max(4, Math.min(slot * 0.78, 56));
|
||||
const barW = Math.max(2, groupW / Math.max(1, visibleSeries.length));
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const value = max * i / 5;
|
||||
@@ -463,25 +511,28 @@ function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke();
|
||||
ctx.textAlign = 'right'; ctx.fillText(`${value.toFixed(value < 1 ? 2 : 1)}`, pad.left - 8, y + 3);
|
||||
}
|
||||
ctx.fillStyle = fill;
|
||||
buckets.forEach((row, index) => {
|
||||
const value = values[index];
|
||||
const x = pad.left + slot * index + (slot - barW) / 2;
|
||||
const barH = (value / max) * plotH;
|
||||
ctx.fillRect(x, pad.top + plotH - barH, barW, barH);
|
||||
starts.forEach((start, bucketIndex) => {
|
||||
const baseX = pad.left + slot * bucketIndex + (slot - groupW) / 2;
|
||||
visibleSeries.forEach((item, seriesIndex) => {
|
||||
const value = maps[seriesIndex].get(start) || 0;
|
||||
const barH = (value / max) * plotH;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = item.comparison ? 0.45 : 0.92;
|
||||
ctx.fillStyle = item.color || historySeriesColor(seriesIndex);
|
||||
ctx.fillRect(baseX + seriesIndex * barW, pad.top + plotH - barH, Math.max(1, barW - 1), barH);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
ctx.fillStyle = text;
|
||||
const ticks = Math.min(6, buckets.length);
|
||||
const ticks = Math.min(6, starts.length);
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const index = ticks === 1 ? 0 : Math.round(i * (buckets.length - 1) / (ticks - 1));
|
||||
const index = ticks === 1 ? 0 : Math.round(i * (starts.length - 1) / (ticks - 1));
|
||||
const x = pad.left + slot * index + slot / 2;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(timeLabel(buckets[index].start, $('#historyHours')?.value), x, height - 18);
|
||||
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('kWh', 8, pad.top + 4);
|
||||
const legend = document.getElementById(`${canvas.id}Legend`);
|
||||
if (legend) legend.innerHTML = `<span class="legend-item"><i class="legend-line" style="--legend-color:${esc(fill)}"></i><span>kWh</span></span>`;
|
||||
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
|
||||
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
|
||||
updateChartZoomControls(canvas.id);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -25,12 +25,12 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
devices: [], zones: [], groups: [], deviceGroups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {},
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyInterval: 'daily', historyEnergy: null, historyLoading: false,
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
|
||||
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
|
||||
+47
-4
@@ -24,6 +24,35 @@ function deviceTransportLabel(device) {
|
||||
return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local';
|
||||
}
|
||||
|
||||
function deviceInstallationForDevice(deviceId) {
|
||||
return (app.deviceGroups || []).find(group => (group.device_ids || []).includes(deviceId)) || null;
|
||||
}
|
||||
|
||||
function deviceInstallationKindLabel(group) {
|
||||
return tr(group?.kind === 'multisplit' ? 'devices.multisplit' : 'devices.split');
|
||||
}
|
||||
|
||||
function installationEnergySourceLabel(group) {
|
||||
if (!group) return '—';
|
||||
if (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && group.ha_energy_entity_id && !group.energy_device_id)) {
|
||||
return group.ha_energy_entity_id ? `Home Assistant · ${group.ha_energy_entity_id}` : 'Home Assistant';
|
||||
}
|
||||
if (group.energy_source === 'gree_cloud' || group.energy_device_id) {
|
||||
const source = app.devices.find(device => device.id === group.energy_device_id);
|
||||
return source ? `GREE Cloud · ${source.name}` : 'GREE Cloud';
|
||||
}
|
||||
return tr('energy.auto');
|
||||
}
|
||||
|
||||
function effectiveDeviceOutdoorTemperature(device) {
|
||||
const group = deviceInstallationForDevice(device?.id);
|
||||
if (group?.outdoor_temperature_device_id) {
|
||||
const source = app.devices.find(item => item.id === group.outdoor_temperature_device_id);
|
||||
if (source?.outdoor_temperature != null) return source.outdoor_temperature;
|
||||
}
|
||||
return device?.outdoor_temperature;
|
||||
}
|
||||
|
||||
function deviceConnectionStatusLabel(device) {
|
||||
const key = {
|
||||
online: 'status.online',
|
||||
@@ -85,7 +114,7 @@ function manualLocalDeviceCard(device) {
|
||||
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">+</button>
|
||||
</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
|
||||
${compressorQueuePanel(managedZone)}
|
||||
<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||||
<div class="fan-row quick-control-row quick-control-row-4">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
|
||||
@@ -123,7 +152,7 @@ function manualCloudDeviceCard(device) {
|
||||
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-temp-step="${tempStep}" data-temp-min="${minTemp}" data-temp-max="${maxTemp}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="${tempStep}" data-device="${esc(device.id)}">+</button>
|
||||
</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
|
||||
${compressorQueuePanel(managedZone)}
|
||||
${modes.length ? `<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>` : ''}
|
||||
${fans.length ? `<div class="fan-row quick-control-row quick-control-row-${Math.min(6, fans.length)}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>` : ''}
|
||||
@@ -148,7 +177,7 @@ function technicalDeviceCard(device) {
|
||||
const model = device.model || tr('common.unavailable');
|
||||
const firmware = device.firmware || tr('common.unavailable');
|
||||
const cid = device.cid || tr('common.unavailable');
|
||||
const error = device.last_error ? `<div class="technical-device-error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
|
||||
<div class="technical-device-head">
|
||||
<div class="device-title"><span class="eyebrow">${esc(tr('devices.technicalUnit'))} · ${esc(deviceTransportLabel(device))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(device.connection_type === 'gree_cloud' ? (device.connection_status || 'unknown').replaceAll('_', ' ') : tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
|
||||
@@ -162,6 +191,7 @@ function technicalDeviceCard(device) {
|
||||
<div><span>${esc(tr('devices.modelFirmware'))}</span><strong>${esc(model)}</strong><small>${esc(firmware)}</small></div>
|
||||
<div><span>${esc(tr('devices.lastSeen'))}</span><strong>${esc(lastSeen)}</strong></div>
|
||||
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
|
||||
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
|
||||
</div>
|
||||
${error}
|
||||
<div class="technical-device-actions">
|
||||
@@ -182,7 +212,7 @@ function cloudTechnicalDeviceCard(device) {
|
||||
const responseTime = deviceResponseTimeLabel(device);
|
||||
const modelCell = device.model ? `<div><span>${esc(tr('devices.model'))}</span><strong>${esc(device.model)}</strong></div>` : '';
|
||||
const firmwareCell = device.firmware ? `<div><span>${esc(tr('devices.firmware'))}</span><strong>${esc(device.firmware)}</strong></div>` : '';
|
||||
const error = device.last_error ? `<div class="technical-device-error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
|
||||
<div class="technical-device-head">
|
||||
<div class="device-title"><span class="eyebrow">${esc(tr('devices.cloudUnit'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(status)}</span> · GREE Cloud</p></div>
|
||||
@@ -198,6 +228,7 @@ function cloudTechnicalDeviceCard(device) {
|
||||
<div><span>${esc(tr('devices.lastSync'))}</span><strong>${esc(lastSync)}</strong></div>
|
||||
<div><span>${esc(tr('devices.lastResponse'))}</span><strong>${esc(lastSeen)}</strong><small>${esc(responseTime)}</small></div>
|
||||
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
|
||||
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
|
||||
</div>
|
||||
${error}
|
||||
<div class="technical-device-actions">
|
||||
@@ -209,7 +240,19 @@ function cloudTechnicalDeviceCard(device) {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderDeviceInstallationsSummary() {
|
||||
const host = $('#deviceGroupsSummary');
|
||||
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);
|
||||
const outdoor = app.devices.find(device => device.id === group.outdoor_temperature_device_id)?.name;
|
||||
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div><span>${esc(tr('energy.source'))}</span><b>${esc(installationEnergySourceLabel(group))}</b>${outdoor ? `<small>${esc(tr('devices.outdoorSourceDevice'))}: ${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
|
||||
}).join('') : '';
|
||||
}
|
||||
|
||||
function renderDevices() {
|
||||
renderDeviceInstallationsSummary();
|
||||
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
||||
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(manualDeviceCard).join('') : empty;
|
||||
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(technicalDeviceCard).join('') : empty;
|
||||
|
||||
+79
-13
@@ -85,6 +85,19 @@ document.addEventListener('click', async event => {
|
||||
finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
if (action === 'edit-device-group') { await openDeviceGroupsDialog(button.dataset.id || ''); return; }
|
||||
if (action === 'delete-device-group') {
|
||||
const group = (app.deviceGroups || []).find(item => item.id === button.dataset.id);
|
||||
if (!group || !confirm(`${tr('actions.delete')} ${group.name}?`)) return;
|
||||
try {
|
||||
await api(`/api/device-groups/${encodeURIComponent(group.id)}`, { method: 'DELETE' });
|
||||
await loadBootstrap();
|
||||
renderDeviceGroupsDialogList();
|
||||
await populateDeviceGroupForm(null);
|
||||
toast(tr('devices.installationDeleted'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
return;
|
||||
}
|
||||
const device = app.devices.find(v => v.id === button.dataset.device);
|
||||
if (action === 'open-ping' && device) { openDevicePing(device.id); return; }
|
||||
if (action === 'ping-toggle') { if (app.pingMonitor.running) stopPingMonitor(); else startPingMonitor(); return; }
|
||||
@@ -361,7 +374,7 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
|
||||
if (isCloud) {
|
||||
const check = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
|
||||
if (!check.ok) throw new Error(check.message || check.status || tr('settings.cloudConnectionFailed'));
|
||||
if (result) { result.hidden = false; result.classList.add('success'); result.textContent = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); }
|
||||
if (result) { const message = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); result.hidden = false; result.classList.add('success'); result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`; }
|
||||
markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return;
|
||||
}
|
||||
const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version);
|
||||
@@ -380,7 +393,8 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
|
||||
}
|
||||
if (result) {
|
||||
result.hidden = false; result.classList.add('success');
|
||||
result.textContent = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
|
||||
const message = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
|
||||
result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`;
|
||||
}
|
||||
markFormClean(form);
|
||||
renderAll();
|
||||
@@ -388,7 +402,8 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
|
||||
} catch (error) {
|
||||
if (result) {
|
||||
result.hidden = false; result.classList.add('error');
|
||||
result.textContent = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
|
||||
const message = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
|
||||
result.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
||||
}
|
||||
markFormClean(form);
|
||||
renderAll();
|
||||
@@ -401,17 +416,22 @@ $('#deviceDetailsForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
||||
const patch = {
|
||||
name: raw.name.trim(),
|
||||
energy_source: raw.energy_source || 'auto',
|
||||
ha_energy_entity_id: raw.ha_energy_entity_id || null,
|
||||
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
||||
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
||||
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
||||
};
|
||||
const deviceId = form.elements.id.value;
|
||||
const installation = deviceInstallationForDevice(deviceId);
|
||||
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
|
||||
const patch = { name: String(raw.name || '').trim() };
|
||||
if (!groupOwnsEnergy) {
|
||||
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
||||
Object.assign(patch, {
|
||||
energy_source: raw.energy_source || 'auto',
|
||||
ha_energy_entity_id: raw.ha_energy_entity_id || null,
|
||||
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
||||
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
||||
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
||||
});
|
||||
}
|
||||
await runFormTask(form, async () => {
|
||||
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
|
||||
const device = await api(`/api/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: patch });
|
||||
updateDevice(device);
|
||||
form.closest('dialog').close();
|
||||
renderAll();
|
||||
@@ -421,6 +441,52 @@ $('#deviceDetailsForm')?.addEventListener('submit', async event => {
|
||||
|
||||
$('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta);
|
||||
|
||||
|
||||
$('#deviceGroupsButton')?.addEventListener('click', () => openDeviceGroupsDialog());
|
||||
$('#deviceGroupNew')?.addEventListener('click', () => populateDeviceGroupForm(null));
|
||||
$('#deviceGroupForm')?.addEventListener('change', event => {
|
||||
const form = event.currentTarget;
|
||||
if (event.target.name === 'kind' && form.kind.value === 'split') {
|
||||
const checked = $$('#deviceGroupDeviceChoices input[name="device_ids"]:checked');
|
||||
checked.slice(1).forEach(input => { input.checked = false; });
|
||||
syncDeviceGroupMemberSelects();
|
||||
}
|
||||
if (event.target.name === 'device_ids') {
|
||||
if (form.kind.value === 'split' && event.target.checked) {
|
||||
$$('#deviceGroupDeviceChoices input[name="device_ids"]:checked').forEach(input => { if (input !== event.target) input.checked = false; });
|
||||
}
|
||||
syncDeviceGroupMemberSelects();
|
||||
}
|
||||
if (event.target.name === 'ha_energy_entity_id') updateDeviceGroupEnergySensorMeta();
|
||||
});
|
||||
$('#deviceGroupForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const ids = selectedDeviceGroupIds();
|
||||
if (!ids.length) return showFormError(form, tr('energy.selectAtLeastOne'));
|
||||
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
||||
const body = {
|
||||
name: form.name.value.trim(),
|
||||
kind: form.kind.value || 'split',
|
||||
device_ids: ids,
|
||||
energy_source: form.energy_source.value || 'auto',
|
||||
energy_device_id: form.energy_device_id.value || null,
|
||||
ha_energy_entity_id: form.ha_energy_entity_id.value || null,
|
||||
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
||||
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
||||
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
||||
outdoor_temperature_device_id: form.outdoor_temperature_device_id.value || null,
|
||||
};
|
||||
const id = form.elements.id.value;
|
||||
await runFormTask(form, async () => {
|
||||
await api(id ? `/api/device-groups/${encodeURIComponent(id)}` : '/api/device-groups', { method: id ? 'PUT' : 'POST', body });
|
||||
await loadBootstrap();
|
||||
renderDeviceGroupsDialogList();
|
||||
await populateDeviceGroupForm(null);
|
||||
toast(tr('devices.installationSaved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#cloudDiagnosticsRefresh')?.addEventListener('click', () => {
|
||||
const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId;
|
||||
if (id) openCloudDiagnostics(id);
|
||||
|
||||
+2
-2
@@ -63,9 +63,9 @@ function showFieldError(field, message) {
|
||||
function showFormError(form, message) {
|
||||
if (!form || !message) return;
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'form-error-summary';
|
||||
summary.className = 'inline-alert error form-error-summary';
|
||||
summary.setAttribute('role', 'alert');
|
||||
summary.textContent = message;
|
||||
summary.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
||||
const anchor = $('.settings-save-bar', form) || $('.form-actions', form);
|
||||
if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor);
|
||||
else form.appendChild(summary);
|
||||
|
||||
+45
-19
@@ -200,40 +200,66 @@ async function handleHistoryAction(button) {
|
||||
|
||||
|
||||
async function loadEnergyHistory() {
|
||||
const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id);
|
||||
if (!energyDevices.length) {
|
||||
app.historyEnergy = null;
|
||||
const targets = normalizeEnergyHistoryTargets();
|
||||
if (!targets.length || !app.historyEnergyTargets.length) {
|
||||
app.historyEnergy = [];
|
||||
return;
|
||||
}
|
||||
if (!energyDevices.some(device => device.id === app.historyEnergyDevice)) app.historyEnergyDevice = energyDevices[0].id;
|
||||
const hours = Number($('#historyHours')?.value || 24);
|
||||
const days = Math.max(1, Math.ceil(hours / 24));
|
||||
app.historyEnergy = await api(`/api/history/energy?device_id=${encodeURIComponent(app.historyEnergyDevice)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000`);
|
||||
const selected = app.historyEnergyTargets.slice(0, 8);
|
||||
app.historyEnergy = await Promise.all(selected.map(targetId => api(`/api/history/energy?target_id=${encodeURIComponent(targetId)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000&compare=${encodeURIComponent(app.historyEnergyCompare)}`)));
|
||||
}
|
||||
|
||||
function renderEnergyHistorySummary() {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
const data = app.historyEnergy;
|
||||
if (!data || data.source === 'none') { host.innerHTML = ''; return; }
|
||||
const summary = data.summary || {};
|
||||
const rows = [
|
||||
[tr('energy.today'), summary.today],
|
||||
[tr('energy.yesterday'), summary.yesterday],
|
||||
[tr('energy.currentMonth'), summary.current_month],
|
||||
[tr('energy.previousMonth'), summary.previous_month],
|
||||
[tr('energy.periodTotal'), summary.period_total],
|
||||
];
|
||||
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(data.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
|
||||
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
|
||||
if (!data.length) { host.innerHTML = ''; return; }
|
||||
if (data.length === 1) {
|
||||
const item = data[0];
|
||||
const summary = item.summary || {};
|
||||
const rows = [
|
||||
[tr('energy.today'), summary.today],
|
||||
[tr('energy.yesterday'), summary.yesterday],
|
||||
[tr('energy.currentMonth'), summary.current_month],
|
||||
[tr('energy.previousMonth'), summary.previous_month],
|
||||
[tr('energy.periodTotal'), summary.period_total],
|
||||
];
|
||||
if (item.comparison) rows.push([tr('energy.comparisonPeriod'), item.comparison.period_total]);
|
||||
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
|
||||
return;
|
||||
}
|
||||
host.innerHTML = data.map(item => `<div class="history-stat"><small>${esc(item.target_name || item.target_id)}</small><strong>${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}${item.comparison ? ` · ${esc(tr('energy.comparisonPeriod'))}: ${Number(item.comparison.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh` : ''}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderEnergyHistory() {
|
||||
const host = $('#historyCharts'); if (!host) return;
|
||||
const data = app.historyEnergy;
|
||||
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
|
||||
renderEnergyHistorySummary();
|
||||
if (!data || data.source === 'none') {
|
||||
if (!data.length) {
|
||||
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
|
||||
return;
|
||||
}
|
||||
const series = [];
|
||||
data.forEach((item, index) => {
|
||||
const color = historySeriesColor(index);
|
||||
series.push({
|
||||
key: `${item.target_id}:current`,
|
||||
label: data.length === 1 ? `${item.target_name} · ${tr('energy.currentPeriod')}` : item.target_name,
|
||||
color,
|
||||
buckets: item.buckets || [],
|
||||
});
|
||||
if (app.historyEnergyCompare !== 'none' && item.comparison?.buckets?.length) {
|
||||
series.push({
|
||||
key: `${item.target_id}:comparison`,
|
||||
label: `${item.target_name} · ${tr('energy.comparisonPeriod')}`,
|
||||
color,
|
||||
comparison: true,
|
||||
buckets: item.comparison.buckets,
|
||||
});
|
||||
}
|
||||
});
|
||||
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
|
||||
drawEnergyBarChart($('#energyConsumptionChart'), data.buckets || [], { height: 360 });
|
||||
drawEnergyBarChart($('#energyConsumptionChart'), series, { height: 360, interval: app.historyEnergyInterval });
|
||||
}
|
||||
|
||||
|
||||
+137
-11
@@ -698,7 +698,7 @@ 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.id.value = device.id;
|
||||
form.elements.id.value = device.id;
|
||||
form.name.value = device.name || '';
|
||||
form.energy_source.value = device.energy_source || 'auto';
|
||||
const title = $('#deviceDetailsTitle');
|
||||
@@ -707,10 +707,24 @@ async function openDeviceDetails(id) {
|
||||
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>';
|
||||
try {
|
||||
const response = await api('/api/integrations/home-assistant/energy-sensors');
|
||||
@@ -723,23 +737,135 @@ async function openDeviceDetails(id) {
|
||||
option.dataset.stateClass = sensor.state_class || '';
|
||||
sensorSelect.append(option);
|
||||
}
|
||||
} catch (_) {
|
||||
// Home Assistant is optional; keep any already-saved entity available even when HA is offline.
|
||||
}
|
||||
if (device.ha_energy_entity_id && ![...sensorSelect.options].some(option => option.value === device.ha_energy_entity_id)) {
|
||||
} 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 = device.ha_energy_entity_id;
|
||||
option.textContent = device.ha_energy_entity_id;
|
||||
option.dataset.unit = device.ha_energy_unit || '';
|
||||
option.dataset.deviceClass = device.ha_energy_device_class || '';
|
||||
option.dataset.stateClass = device.ha_energy_state_class || '';
|
||||
option.value = energyEntity;
|
||||
option.textContent = energyEntity;
|
||||
option.dataset.unit = energyUnit || '';
|
||||
option.dataset.deviceClass = energyDeviceClass || '';
|
||||
option.dataset.stateClass = energyStateClass || '';
|
||||
sensorSelect.append(option);
|
||||
}
|
||||
sensorSelect.value = device.ha_energy_entity_id || '';
|
||||
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>';
|
||||
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');
|
||||
|
||||
@@ -46,6 +46,8 @@ async function handleWebSocketMessage(event) {
|
||||
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (['device_group.updated', 'device_group.created'].includes(message.event)) { const i = app.deviceGroups.findIndex(v => v.id === data.id); if (i >= 0) app.deviceGroups[i] = data; else app.deviceGroups.push(data); renderDevices(); renderHistoryNavigation(); }
|
||||
else if (message.event === 'device_group.deleted') { app.deviceGroups = app.deviceGroups.filter(v => v.id !== data.id); renderDevices(); renderHistoryNavigation(); }
|
||||
else if (['schedule.updated', 'schedule.created'].includes(message.event)) { const i = app.schedules.findIndex(v => v.id === data.id); if (i >= 0) app.schedules[i] = data; else app.schedules.push(data); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'schedule.deleted') { app.schedules = app.schedules.filter(v => v.id !== data.id); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); }
|
||||
|
||||
+9
-3
@@ -23,8 +23,9 @@ function currentHistoryPath() {
|
||||
if (hours !== '24') params.set('hours', hours);
|
||||
if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone);
|
||||
if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice);
|
||||
if (tab === 'energy' && app.historyEnergyDevice) params.set('device', app.historyEnergyDevice);
|
||||
if (tab === 'energy' && app.historyEnergyTargets?.length) params.set('targets', app.historyEnergyTargets.join(','));
|
||||
if (tab === 'energy' && app.historyEnergyInterval !== 'daily') params.set('interval', app.historyEnergyInterval);
|
||||
if (tab === 'energy' && app.historyEnergyCompare !== 'none') params.set('compare', app.historyEnergyCompare);
|
||||
if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor);
|
||||
if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries));
|
||||
const query = params.toString();
|
||||
@@ -73,8 +74,13 @@ function applyRouteFromLocation() {
|
||||
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
|
||||
app.historyZone = params.get('zone') || 'all';
|
||||
app.historyDevice = params.get('device') || 'all';
|
||||
if (app.historyTab === 'energy') app.historyEnergyDevice = params.get('device') || app.historyEnergyDevice || '';
|
||||
if (app.historyTab === 'energy' && ['hourly', 'daily', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval');
|
||||
if (app.historyTab === 'energy') {
|
||||
const targetParam = params.get('targets') || params.get('device') || '';
|
||||
app.historyEnergyTargets = targetParam ? targetParam.split(',').filter(Boolean).slice(0, 8) : (app.historyEnergyTargets || []);
|
||||
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
|
||||
if (['hourly', 'daily', 'weekly', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval');
|
||||
if (['none', 'previous_day', 'previous_period', 'previous_year'].includes(params.get('compare'))) app.historyEnergyCompare = params.get('compare');
|
||||
}
|
||||
app.historySensor = params.get('sensor') || 'all';
|
||||
const hours = params.get('hours');
|
||||
if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
|
||||
|
||||
+41
-19
@@ -395,28 +395,50 @@ setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 3
|
||||
|
||||
|
||||
async function refreshGreeCloudRuntimeStatus() {
|
||||
const account = $('#greeCloudAccountStatus');
|
||||
const mqtt = $('#greeCloudMqttStatus');
|
||||
if (!account || !mqtt) return;
|
||||
const setText = (selector, value) => { const node = $(selector); if (node) node.textContent = value; };
|
||||
const summary = $('.cloud-runtime-summary');
|
||||
if (!summary) return;
|
||||
const metricSelectors = [
|
||||
'#greeCloudAccountStatus', '#greeCloudMqttStatus', '#greeCloudDevicesOnline', '#greeCloudRestResponseTime',
|
||||
'#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince',
|
||||
'#greeCloudBroker', '#greeCloudTraffic',
|
||||
];
|
||||
const setMetric = (selector, value, visible) => {
|
||||
const node = $(selector); if (!node) return 0;
|
||||
const row = node.closest('div');
|
||||
if (row) row.hidden = !visible;
|
||||
if (visible) node.textContent = value;
|
||||
return visible ? 1 : 0;
|
||||
};
|
||||
const lastContact = $('#greeCloudLastContact');
|
||||
const lastContactRow = lastContact?.closest('small');
|
||||
try {
|
||||
const status = await api('/api/integrations/gree-cloud/status');
|
||||
const runtime = status.runtime || {};
|
||||
account.textContent = String(status.account_status || 'unknown').replaceAll('_', ' ');
|
||||
mqtt.textContent = String(status.mqtt_status || 'disconnected').replaceAll('_', ' ');
|
||||
setText('#greeCloudDevicesOnline', `${Number(status.online_device_count || 0)} / ${Number(status.device_count || 0)}`);
|
||||
setText('#greeCloudRestResponseTime', status.last_rest_response_time_ms != null && Number.isFinite(Number(status.last_rest_response_time_ms)) ? `${Number(status.last_rest_response_time_ms)} ms` : tr('common.unavailable'));
|
||||
setText('#greeCloudResponseTime', runtime.last_response_time_ms != null && Number.isFinite(Number(runtime.last_response_time_ms)) ? `${Number(runtime.last_response_time_ms)} ms` : tr('common.unavailable'));
|
||||
setText('#greeCloudLastDeviceResponse', runtime.last_device_response ? dateTime(runtime.last_device_response) : tr('common.unavailable'));
|
||||
setText('#greeCloudLastMqttMessage', runtime.last_mqtt_message ? dateTime(runtime.last_mqtt_message) : tr('common.unavailable'));
|
||||
setText('#greeCloudConnectedSince', runtime.mqtt_connected_since ? dateTime(runtime.mqtt_connected_since) : tr('common.unavailable'));
|
||||
setText('#greeCloudBroker', runtime.broker_host || tr('common.unavailable'));
|
||||
setText('#greeCloudTraffic', `${Number(runtime.requests_sent || 0)} / ${Number(runtime.responses_received || 0)} / ${Number(runtime.request_timeouts || 0)}`);
|
||||
if (status.last_successful_contact) setText('#greeCloudLastContact', dateTime(status.last_successful_contact));
|
||||
const enabled = status.enabled === true;
|
||||
const deviceCount = Number(status.device_count || 0);
|
||||
const onlineCount = Number(status.online_device_count || 0);
|
||||
const accountStatus = String(status.account_status || '').trim();
|
||||
const mqttStatus = String(status.mqtt_status || '').trim();
|
||||
const restMs = Number(status.last_rest_response_time_ms);
|
||||
const responseMs = Number(runtime.last_response_time_ms);
|
||||
const traffic = [Number(runtime.requests_sent || 0), Number(runtime.responses_received || 0), Number(runtime.request_timeouts || 0)];
|
||||
let visibleCount = 0;
|
||||
visibleCount += setMetric('#greeCloudAccountStatus', accountStatus.replaceAll('_', ' '), enabled && !!accountStatus && accountStatus !== 'disabled');
|
||||
visibleCount += setMetric('#greeCloudMqttStatus', mqttStatus.replaceAll('_', ' '), enabled && !!mqttStatus && (mqttStatus === 'connected' || deviceCount > 0 || !['disconnected', 'disabled'].includes(mqttStatus)));
|
||||
visibleCount += setMetric('#greeCloudDevicesOnline', `${onlineCount} / ${deviceCount}`, deviceCount > 0);
|
||||
visibleCount += setMetric('#greeCloudRestResponseTime', `${restMs} ms`, Number.isFinite(restMs) && restMs >= 0);
|
||||
visibleCount += setMetric('#greeCloudResponseTime', `${responseMs} ms`, Number.isFinite(responseMs) && responseMs >= 0);
|
||||
visibleCount += setMetric('#greeCloudLastDeviceResponse', dateTime(runtime.last_device_response), !!runtime.last_device_response);
|
||||
visibleCount += setMetric('#greeCloudLastMqttMessage', dateTime(runtime.last_mqtt_message), !!runtime.last_mqtt_message);
|
||||
visibleCount += setMetric('#greeCloudConnectedSince', dateTime(runtime.mqtt_connected_since), !!runtime.mqtt_connected_since);
|
||||
visibleCount += setMetric('#greeCloudBroker', runtime.broker_host || '', !!String(runtime.broker_host || '').trim());
|
||||
visibleCount += setMetric('#greeCloudTraffic', traffic.join(' / '), traffic.some(value => value > 0));
|
||||
summary.hidden = visibleCount === 0;
|
||||
if (lastContactRow) lastContactRow.hidden = !status.last_successful_contact;
|
||||
if (lastContact && status.last_successful_contact) lastContact.textContent = dateTime(status.last_successful_contact);
|
||||
} catch (_) {
|
||||
account.textContent = 'unknown';
|
||||
mqtt.textContent = 'disconnected';
|
||||
['#greeCloudDevicesOnline', '#greeCloudRestResponseTime', '#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince', '#greeCloudBroker', '#greeCloudTraffic'].forEach(selector => setText(selector, tr('common.unavailable')));
|
||||
metricSelectors.forEach(selector => setMetric(selector, '', false));
|
||||
summary.hidden = true;
|
||||
if (lastContactRow) lastContactRow.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -321,8 +321,9 @@ document.addEventListener('change', event => {
|
||||
const target = event.target;
|
||||
if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historyEnergyDeviceSelect') { app.historyEnergyDevice = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historyEnergyTargetSelect') { app.historyEnergyTargets = [...target.selectedOptions].map(option => option.value).slice(0, 8); app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historyEnergyCompare') { app.historyEnergyCompare = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.name === 'influx_version') updateInfluxFields();
|
||||
});
|
||||
@@ -445,7 +446,8 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => {
|
||||
if (resultBox) {
|
||||
resultBox.hidden = false;
|
||||
resultBox.classList.add(result.ok ? 'success' : 'error');
|
||||
resultBox.textContent = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
|
||||
const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
|
||||
resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
||||
}
|
||||
if (result.ok) {
|
||||
const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud);
|
||||
@@ -453,7 +455,7 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => {
|
||||
renderSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.textContent = error.message; }
|
||||
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(error.message)}</strong>`; }
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user