This commit is contained in:
Mateusz Gruszczyński
2026-09-14 16:32:28 +02:00
parent c3fbc5ccc6
commit 021cbddba5
68 changed files with 7780 additions and 202 deletions
+2
View File
@@ -1,6 +1,7 @@
function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode || 'cool') {
const application = sections?.application || {};
const gree = sections?.gree || {};
const greeCloud = sections?.gree_cloud || {};
const history = sections?.history || {};
const influxdb = sections?.influxdb || {};
const notifications = sections?.notifications || {};
@@ -15,6 +16,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
discovery_timeout_ms: Number(gree.discovery_timeout_ms),
discovery_broadcast: gree.discovery_broadcast,
suppress_device_beep: !!gree.suppress_device_beep,
gree_cloud: greeCloud,
compressor_protection_enabled: gree.compressor_protection_enabled !== false,
compressor_protection_seconds: Number(gree.compressor_protection_seconds),
history_retention_days: Number(history.retention_days),
+69 -2
View File
@@ -35,6 +35,15 @@ function renderHistoryNavigation() {
} else if (app.historyTab === 'devices') {
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>`
: `<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 intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
} 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;
@@ -50,6 +59,13 @@ async function loadHistory() {
app.historyLoading = true;
const hours = $('#historyHours')?.value || '24';
try {
if (app.historyTab === 'energy') {
await loadEnergyHistory();
renderHistoryNavigation();
renderHistoryPage();
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
return;
}
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] };
app.historyCounts = data.counts || {};
@@ -99,8 +115,12 @@ function redrawHistoryChart(id) {
const canvas = document.getElementById(id);
if (!runtime || !canvas) return;
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
if (runtime.kind === 'energy') {
drawEnergyBarChart(canvas, runtime.buckets, runtime.options);
} else {
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
}
updateChartZoomControls(id);
}
@@ -418,6 +438,53 @@ 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 } = {}) {
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 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));
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;
const y = pad.top + plotH - (value / max) * plotH;
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);
});
ctx.fillStyle = text;
const ticks = Math.min(6, buckets.length);
for (let i = 0; i < ticks; i++) {
const index = ticks === 1 ? 0 : Math.round(i * (buckets.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.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>`;
updateChartZoomControls(canvas.id);
}
function renderLegend(host, series) {
if (!host) return;
const chartId = host.id.replace(/Legend$/, '');
+1 -1
View File
@@ -30,7 +30,7 @@ const app = {
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', historyLoading: false,
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyInterval: 'daily', historyEnergy: null, historyLoading: 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(),
+113 -8
View File
@@ -1,11 +1,12 @@
function deviceFeaturePanel(device) {
const features = [
const allFeatures = [
['light', 'supports_light', tr('devices.light')],
['xfan', 'supports_xfan', tr('devices.xfan')],
['health', 'supports_health', tr('devices.health')],
['air', 'supports_air', tr('devices.air')],
['sleep', 'supports_sleep', tr('devices.sleep')],
].filter(([, support]) => device[support] === true);
];
const features = allFeatures.filter(([, support]) => device[support] === true);
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field, , label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
@@ -19,7 +20,23 @@ function disabledZoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId && zone.enabled === false) || null;
}
function deviceTransportLabel(device) {
return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local';
}
function deviceConnectionStatusLabel(device) {
const key = {
online: 'status.online',
offline: 'status.offline',
cloud_disconnected: 'status.cloudDisconnected',
authentication_error: 'status.authenticationError',
unknown: 'status.unknown',
}[device.connection_status || (device.online ? 'online' : 'offline')];
return key ? tr(key) : String(device.connection_status || tr('status.unknown')).replaceAll('_', ' ');
}
function deviceProtocolLabel(device) {
if (device.connection_type === 'gree_cloud') return 'MQTT / TLS';
return device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 AES-GCM' : device.protocol_version === 1 ? 'V1 AES-ECB' : tr('devices.protocolAuto');
}
@@ -28,6 +45,15 @@ function deviceResponseTimeLabel(device) {
return Number.isFinite(value) ? `${Math.max(0, Math.round(value))} ms` : '— ms';
}
function manualDeviceStatus(device) {
const label = deviceConnectionStatusLabel(device);
const detail = device.last_error ? `<span class="manual-device-status-error" title="${esc(device.last_error)}">${esc(device.last_error)}</span>` : '';
const pending = device.pending_command ? `<span class="manual-device-status-pending">${esc(tr('common.pending'))}</span>` : '';
return `<p class="manual-device-status" role="status"><span class="status ${device.online ? 'online' : ''}">${esc(label)}</span>${detail}${pending}</p>`;
}
// Keep Local Manual Control visually and behaviorally identical to the pre-Cloud UI.
// The only transport-specific addition is the existing Local badge in the title row.
function manualDeviceProblem(device) {
if (!device.enabled) {
return `<div class="manual-device-problem error" role="status"><strong>${esc(tr('devices.manualDeviceDisabled'))}</strong></div>`;
@@ -41,7 +67,7 @@ function manualDeviceProblem(device) {
return '';
}
function manualDeviceCard(device) {
function manualLocalDeviceCard(device) {
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
const fans = [0, 1, 3, 5];
const disabledZone = disabledZoneForDevice(device.id);
@@ -49,7 +75,7 @@ function manualDeviceCard(device) {
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
return `<article class="device-card quick-control-card quick-device-control ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3></div>
<div class="device-title"><h3>${esc(device.name)}</h3><span class="badge">Local</span></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
</div>
${warning}
@@ -72,7 +98,50 @@ function manualDeviceCard(device) {
</article>`;
}
function manualCloudDeviceCard(device) {
const caps = device.capabilities || {};
const modes = caps.mode === false ? [] : ['auto', 'cool', 'dry', 'fan', 'heat'];
const reportedFans = Array.isArray(caps.fan_modes) ? caps.fan_modes.map(Number).filter(Number.isFinite) : [];
const fans = reportedFans.length ? reportedFans : [0, 1, 3, 5];
const tempStep = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
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 disabledZone = disabledZoneForDevice(device.id);
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
return `<article class="device-card quick-control-card quick-device-control cloud-manual-device ${device.pending_command ? 'cloud-command-pending' : ''} ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title">
<div class="manual-device-title-row"><h3>${esc(device.name)}</h3><span class="badge">GREE Cloud</span></div>
${manualDeviceStatus(device)}
</div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
</div>
${warning}
<div class="temperature-control">
<button data-action="temperature" data-delta="${-tempStep}" data-device="${esc(device.id)}"></button>
<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>
${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>` : ''}
<div class="device-toggles quick-control-row quick-control-row-3">
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>`}
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div>
${deviceFeaturePanel(device)}
</article>`;
}
function manualDeviceCard(device) {
return device.connection_type === 'gree_cloud' ? manualCloudDeviceCard(device) : manualLocalDeviceCard(device);
}
function technicalDeviceCard(device) {
if (device.connection_type === 'gree_cloud') return cloudTechnicalDeviceCard(device);
const protocol = deviceProtocolLabel(device);
const responseTime = deviceResponseTimeLabel(device);
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('common.unavailable');
@@ -82,11 +151,11 @@ function technicalDeviceCard(device) {
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>` : '';
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'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>
<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>
${device.connection_type === 'gree_cloud' ? `<span class="badge">GREE Cloud</span>` : `<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>`}
</div>
<div class="technical-device-grid">
<div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip)}:${esc(device.port)}</strong></div>
<div><span>${esc(tr('devices.address'))}</span><strong>${device.connection_type === 'gree_cloud' ? 'GREE Cloud' : `${esc(device.ip)}:${esc(device.port)}`}</strong></div>
<div><span>MAC</span><strong>${esc(device.mac)}</strong></div>
<div><span>CID</span><strong>${esc(cid)}</strong></div>
<div><span>${esc(tr('devices.protocol'))}</span><strong>${esc(protocol)}</strong></div>
@@ -98,7 +167,43 @@ function technicalDeviceCard(device) {
<div class="technical-device-actions">
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
<button type="button" data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.technicalConfig'))}</button>
${device.simulated ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
<button type="button" data-action="energy-config" data-device="${esc(device.id)}">${esc(tr('energy.title'))}</button>
${device.simulated || device.connection_type === 'gree_cloud' ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
</div>
</article>`;
}
function cloudTechnicalDeviceCard(device) {
const lastSync = device.last_cloud_sync ? dateTime(device.last_cloud_sync) : tr('devices.noCloudSyncYet');
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('devices.noCloudResponseYet');
const cloudId = device.cloud_device_id || device.mac || tr('common.unavailable');
const status = deviceConnectionStatusLabel(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>` : '';
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>
<span class="badge">GREE Cloud</span>
</div>
<div class="technical-device-grid">
<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(cloudId)}</strong></div>
<div><span>MAC</span><strong>${esc(device.mac || cloudId)}</strong></div>
${modelCell}
${firmwareCell}
<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>
</div>
${error}
<div class="technical-device-actions">
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
<button type="button" data-action="cloud-details" data-device="${esc(device.id)}">${esc(tr('devices.cloudDetails'))}</button>
<button type="button" data-action="cloud-diagnostics" data-device="${esc(device.id)}">${esc(tr('devices.cloudDiagnostics'))}</button>
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
</div>
</article>`;
+74 -1
View File
@@ -73,6 +73,18 @@ document.addEventListener('click', async event => {
return;
}
const action = button.dataset.action; if (!action) return;
if (action === 'add-cloud-device') {
const cloudId = button.dataset.cloudId; if (!cloudId) return;
button.disabled = true;
try {
await api(`/api/integrations/gree-cloud/devices/${encodeURIComponent(cloudId)}/add`, { method: 'POST' });
await loadBootstrap();
await loadCloudDiscovery({ open: false });
toast(tr('devices.cloudAdded'));
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
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; }
@@ -84,6 +96,9 @@ document.addEventListener('click', async event => {
if (action === 'poll' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' })); renderAll(); toast(tr('devices.readDone')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
if (action === 'bind' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' })); renderAll(); toast(tr('devices.bound')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
if (action === 'energy-config' && device) return openDeviceDetails(device.id);
if (action === 'cloud-details' && device) return openDeviceDetails(device.id);
if (action === 'cloud-diagnostics' && device) return openCloudDiagnostics(device.id);
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
if (action === 'house-mode') {
try {
@@ -209,6 +224,13 @@ $('#discoverButton').addEventListener('click', () => {
form.protocol_version.value = '0'; form.passes.value = '3'; form.timeout_ms.value = String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
openDialog('discoverDialog');
});
$('#cloudDiscoverButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await loadCloudDiscovery(); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#historyRefresh').addEventListener('click', loadHistory);
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
$('#logsRefresh').addEventListener('click', loadLogs);
@@ -328,12 +350,20 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
const result = $('#deviceConfigCheckResult');
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
await runFormTask(form, async () => {
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) } });
const isCloud = previous?.connection_type === 'gree_cloud';
const patch = isCloud ? { name: raw.name.trim() } : { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) };
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
updateDevice(device);
if (saveMode !== 'check') {
form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); return;
}
try {
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) }); }
markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return;
}
const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version);
if (!device.simulated && protocolChanged) {
device = await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' });
@@ -367,6 +397,35 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
});
});
$('#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,
};
await runFormTask(form, async () => {
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
updateDevice(device);
form.closest('dialog').close();
renderAll();
toast(tr('common.saved'));
});
});
$('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta);
$('#cloudDiagnosticsRefresh')?.addEventListener('click', () => {
const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId;
if (id) openCloudDiagnostics(id);
});
$('#pingDeviceSelect')?.addEventListener('change', event => {
app.pingMonitor.targetId = event.target.value;
renderPingDialog();
@@ -495,3 +554,17 @@ $('#automationForm').addEventListener('submit', async event => {
await api(id ? `/api/automations/${encodeURIComponent(id)}` : '/api/automations', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
});
});
$('#greeCloudReconnectButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
try {
button.disabled = true;
await api('/api/integrations/gree-cloud/reconnect', { method: 'POST' });
await refreshGreeCloudRuntimeStatus();
toast(tr('common.saved'));
} catch (error) {
toast(error.message, true);
} finally {
button.disabled = false;
}
});
+41
View File
@@ -1,5 +1,6 @@
function renderHistorySummary() {
const host = $('#historySummary'); if (!host) return;
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
const deviceRows = app.historyData.devices, zoneRows = app.historyData.zones, sensorRows = app.historyData.sensors;
const cards = [
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
@@ -155,6 +156,7 @@ function renderHistoryPage() {
if (app.historyTab === 'overview') renderOverviewHistory();
else if (app.historyTab === 'zones') renderZoneHistory();
else if (app.historyTab === 'devices') renderDeviceHistory();
else if (app.historyTab === 'energy') renderEnergyHistory();
else if (app.historyTab === 'sensors') renderSensorHistory();
else renderCustomHistory();
}
@@ -196,3 +198,42 @@ 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;
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`);
}
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('');
}
function renderEnergyHistory() {
const host = $('#historyCharts'); if (!host) return;
const data = app.historyEnergy;
renderEnergyHistorySummary();
if (!data || data.source === 'none') {
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
return;
}
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
drawEnergyBarChart($('#energyConsumptionChart'), data.buckets || [], { height: 360 });
}
+141 -8
View File
@@ -213,13 +213,62 @@ function openDevicePing(id) {
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 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 });
});
@@ -246,7 +295,12 @@ function queueDeviceTemperature(id, delta) {
if (!confirmManualCommandForDisabledZone(id)) return;
draft = { ...(draft || {}), disabledZoneConfirmed: true };
}
const next = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), 8, 30);
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 = {
@@ -365,7 +419,10 @@ function beginInlineTemperatureEdit(target) {
}
const value = parseDecimal(target.dataset.value);
if (!Number.isFinite(value) || !kind || !id) return;
const decimals = kind === 'zone' ? 1 : 0;
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';
@@ -383,8 +440,8 @@ function beginInlineTemperatureEdit(target) {
if (finished) return;
finished = true;
const parsed = parseDecimal(input.value);
if (!commit || !Number.isFinite(parsed) || parsed < 8 || parsed > 30) {
if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', { min: 8, max: 30 }), true);
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;
}
@@ -394,7 +451,10 @@ function beginInlineTemperatureEdit(target) {
return;
}
const device = app.devices.find(item => item.id === id);
if (device) await sendDeviceCommand(id, { target_temperature: Math.round(clamp(parsed, 8, 30)) });
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(); }
@@ -419,7 +479,9 @@ function showDiscoveryNames(ids) {
function populateDeviceRename(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#renameDeviceForm'); form.reset();
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);
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');
@@ -631,3 +693,74 @@ function populateAutomation(id) {
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.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 source = form.energy_source;
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.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 || '';
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)) {
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 || '';
sensorSelect.append(option);
}
sensorSelect.value = device.ha_energy_entity_id || '';
updateDeviceEnergySensorMeta();
openDialog('deviceDetailsDialog');
}
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);
}
}
+3
View File
@@ -55,6 +55,7 @@ async function handleWebSocketMessage(event) {
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); }
else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.gree_cloud.updated') { applySettingsSection('greeCloud', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.history.updated') { applySettingsSection('history', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderLogRetention(); }
else if (message.event === 'settings.influxdb.updated') { applySettingsSection('influxdb', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.notifications.updated') { applySettingsSection('notifications', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
@@ -77,6 +78,8 @@ async function handleWebSocketMessage(event) {
renderSystemInfo();
}
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'gree_cloud.request') { if (app.settings?.debug?.overlay_enabled) debugLine('CLOUD', `${data.operation || 'request'} · ${data.phase || '?'}`, `${data.device_name || data.device_id || data.transport || ''}${data.duration_ms == null ? '' : ` · ${data.duration_ms} ms`}`, message.timestamp, data); }
else if (message.event === 'gree_cloud.mqtt') { if (app.settings?.debug?.overlay_enabled) debugLine('MQTT', `${data.direction || '?'}`, `${data.topic || data.broker || ''}${data.device_id ? ` · ${data.device_id}` : ''}`, message.timestamp, data); }
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
else if (message.event === 'log.updated') { if (app.currentView === 'logs') loadLogs(); }
+5 -1
View File
@@ -13,7 +13,7 @@ const VIEW_ROUTES = Object.freeze({
logs: '/events',
});
const ROUTE_VIEWS = Object.freeze(Object.fromEntries(Object.entries(VIEW_ROUTES).map(([view, path]) => [path, view])));
const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'sensors', 'custom']);
const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'energy', 'sensors', 'custom']);
let routerInitialized = false;
function currentHistoryPath() {
@@ -23,6 +23,8 @@ 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.historyEnergyInterval !== 'daily') params.set('interval', app.historyEnergyInterval);
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();
@@ -71,6 +73,8 @@ 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');
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;
+55 -5
View File
@@ -178,6 +178,17 @@ function renderSettings() {
form.event_log_retention_days.value = app.settings.event_log_retention_days || 30;
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
const cloud = app.settings.gree_cloud || {};
form.gree_cloud_enabled.checked = !!cloud.enabled;
form.gree_cloud_region.value = cloud.region || 'Europe';
form.gree_cloud_username.value = cloud.username || '';
form.gree_cloud_password.value = '';
form.gree_cloud_password.placeholder = cloud.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.gree_cloud_polling_interval_seconds.value = Number(cloud.polling_interval_seconds || 60);
const cloudInstance = $('#greeCloudInstanceId');
if (cloudInstance) cloudInstance.textContent = cloud.installation_id || '—';
const cloudLastContact = $('#greeCloudLastContact');
if (cloudLastContact) cloudLastContact.textContent = cloud.last_successful_contact ? dateTime(cloud.last_successful_contact) : tr('common.unavailable');
form.compressor_protection_enabled.checked = app.settings.compressor_protection_enabled !== false;
form.compressor_protection_minutes.value = (Number(app.settings.compressor_protection_seconds || 180) / 60).toFixed(1).replace(/\.0$/, '');
updateCompressorProtectionFields();
@@ -195,6 +206,8 @@ function renderSettings() {
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
form.debug_cloud_requests.checked = !!app.settings.debug?.cloud_requests;
form.debug_cloud_mqtt.checked = !!app.settings.debug?.cloud_mqtt;
const n = app.settings.notifications || {};
form.notifications_enabled.checked = !!n.enabled; form.notifications_mode.value = n.mode || 'problems'; form.notifications_provider.value = n.provider || 'pushover';
form.pushover_app_token.value = ''; form.pushover_user_key.value = ''; form.slack_webhook_url.value = ''; form.discord_webhook_url.value = '';
@@ -221,6 +234,7 @@ function renderSettings() {
renderSimulationModeBanner();
setSettingsTab(app.settingsTab);
markFormClean(form);
void refreshGreeCloudRuntimeStatus();
}
function updateCompressorProtectionFields() {
@@ -238,7 +252,7 @@ function renderSimulationModeBanner() {
}
function setSettingsTab(tab) {
app.settingsTab = tab === 'gree' ? 'gree' : 'app';
app.settingsTab = ['gree', 'cloud'].includes(tab) ? tab : 'app';
$$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; });
$$('[data-settings-tab]').forEach(button => {
const active = button.dataset.settingsTab === app.settingsTab;
@@ -331,9 +345,16 @@ function renderDebugOverlay() {
const enabled = !!app.settings?.debug?.overlay_enabled;
overlay.hidden = !enabled;
if (!enabled) return;
if (!['all', 'requests', 'gree'].includes(app.debugFilter)) app.debugFilter = 'all';
if (!['all', 'requests', 'gree', 'cloud', 'mqtt'].includes(app.debugFilter)) app.debugFilter = 'all';
const status = $('#debugOverlayStatus');
if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly');
if (status) {
const enabledSources = [
app.settings?.debug?.gree_frames ? tr('debug.gree') : '',
app.settings?.debug?.cloud_requests ? tr('debug.cloud') : '',
app.settings?.debug?.cloud_mqtt ? tr('debug.mqtt') : '',
].filter(Boolean);
status.textContent = enabledSources.length ? `${tr('debug.liveSources')}: ${enabledSources.join(' · ')}` : tr('debug.apiOnly');
}
$$('[data-debug-filter]', overlay).forEach(button => {
const active = button.dataset.debugFilter === app.debugFilter;
button.classList.toggle('active', active);
@@ -343,13 +364,15 @@ function renderDebugOverlay() {
const visible = app.debugLines.filter(line => {
if (app.debugFilter === 'gree') return line.source === 'GREE';
if (app.debugFilter === 'requests') return line.source === 'HTTP';
if (app.debugFilter === 'cloud') return line.source === 'CLOUD';
if (app.debugFilter === 'mqtt') return line.source === 'MQTT';
return true;
}).slice(-120);
host.innerHTML = visible.length ? visible.map(line => {
const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`;
const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : 'api';
const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : line.source === 'CLOUD' ? 'cloud' : line.source === 'MQTT' ? 'mqtt' : 'api';
return `<div class="debug-line source-${sourceClass}"><time>${esc(new Date(line.timestamp).toLocaleTimeString(locale()))}</time><b>${esc(line.source)}</b><span>${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}</span></div>`;
}).join('') : `<div class="debug-empty">${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : 'debug.empty'))}</div>`;
}).join('') : `<div class="debug-empty">${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : app.debugFilter === 'cloud' ? 'debug.emptyCloud' : app.debugFilter === 'mqtt' ? 'debug.emptyMqtt' : 'debug.empty'))}</div>`;
host.scrollTop = host.scrollHeight;
}
@@ -370,3 +393,30 @@ function formatDuration(seconds) {
setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000);
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; };
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));
} catch (_) {
account.textContent = 'unknown';
mqtt.textContent = 'disconnected';
['#greeCloudDevicesOnline', '#greeCloudRestResponseTime', '#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince', '#greeCloudBroker', '#greeCloudTraffic'].forEach(selector => setText(selector, tr('common.unavailable')));
}
}
+89 -2
View File
@@ -1,6 +1,7 @@
const SETTINGS_ENDPOINTS = {
application: '/api/settings/application',
gree: '/api/settings/gree',
greeCloud: '/api/settings/gree-cloud',
history: '/api/settings/history',
influxdb: '/api/settings/influxdb',
notifications: '/api/settings/notifications',
@@ -13,6 +14,7 @@ function applySettingsSection(section, data) {
app.settings = app.settings || {};
if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled;
else if (section === 'gree') Object.assign(app.settings, data);
else if (section === 'greeCloud') app.settings.gree_cloud = data;
else if (section === 'history') {
app.settings.history_retention_days = Number(data.retention_days);
app.settings.history_compaction_enabled = data.compaction_enabled !== false;
@@ -44,6 +46,18 @@ function greeSettingsBodyFromForm(form) {
};
}
function greeCloudSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = {
enabled: form.gree_cloud_enabled.checked,
region: raw.gree_cloud_region || 'Europe',
username: raw.gree_cloud_username || '',
polling_interval_seconds: Number(raw.gree_cloud_polling_interval_seconds || 60),
};
if (raw.gree_cloud_password) body.password = raw.gree_cloud_password;
return body;
}
function historySettingsBodyFromForm(form, eventRetentionDays = null) {
const raw = Object.fromEntries(new FormData(form));
return {
@@ -99,7 +113,12 @@ function notificationSettingsBodyFromForm(form) {
}
function debugSettingsBodyFromForm(form) {
return { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked };
return {
overlay_enabled: form.debug_overlay_enabled.checked,
gree_frames: form.debug_gree_frames.checked,
cloud_requests: form.debug_cloud_requests.checked,
cloud_mqtt: form.debug_cloud_mqtt.checked,
};
}
function nightSettingsBodyFromForm(form) {
@@ -144,9 +163,10 @@ function refreshSettingsUi() {
}
async function saveMainSettings(form, notify = true) {
const [application, gree, history, influxdb, notifications, debug] = await Promise.all([
const [application, gree, greeCloud, history, influxdb, notifications, debug] = await Promise.all([
api(SETTINGS_ENDPOINTS.application, { method: 'PUT', body: applicationSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.gree, { method: 'PUT', body: greeSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.influxdb, { method: 'PUT', body: influxDbSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.notifications, { method: 'PUT', body: notificationSettingsBodyFromForm(form) }),
@@ -154,6 +174,7 @@ async function saveMainSettings(form, notify = true) {
]);
applySettingsSection('application', application);
applySettingsSection('gree', gree);
applySettingsSection('greeCloud', greeCloud);
applySettingsSection('history', history);
applySettingsSection('influxdb', influxdb);
applySettingsSection('notifications', notifications);
@@ -300,6 +321,8 @@ 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 === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
else if (target.name === 'influx_version') updateInfluxFields();
});
@@ -385,3 +408,67 @@ document.addEventListener('click', event => {
renderFlowSharedInputs();
updateDirtyIndicator($('#homeAssistantForm'));
});
async function saveGreeCloudSettings(form = $('#settingsForm')) {
const data = await api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) });
applySettingsSection('greeCloud', data);
return data;
}
function renderCloudDiscoveryDevices(devices) {
const list = $('#cloudDiscoveryList');
if (!list) return;
const items = Array.isArray(devices) ? devices : [];
list.innerHTML = items.length ? items.map(device => `
<div class="discovery-name-row cloud-discovery-row">
<span><strong>${esc(device.name || 'GREE')}</strong><small>${esc(device.model || 'GREE')} · ${esc(device.mac || device.id)} · ${esc(tr(device.online ? 'status.online' : 'status.offline'))}</small></span>
<button type="button" class="${device.already_added ? 'secondary' : 'primary'}" data-action="add-cloud-device" data-cloud-id="${esc(device.id)}" ${device.already_added ? 'disabled' : ''}>${esc(device.already_added ? tr('devices.cloudAlreadyAdded') : tr('actions.add'))}</button>
</div>`).join('') : `<div class="empty"><strong>${esc(tr('devices.cloudDiscoveryEmpty'))}</strong>${esc(tr('devices.cloudDiscoveryEmptyHint'))}</div>`;
}
async function loadCloudDiscovery({ open = true } = {}) {
const result = await api('/api/integrations/gree-cloud/devices');
renderCloudDiscoveryDevices(result.devices || []);
if (open) openDialog('cloudDiscoveryDialog');
return result;
}
$('#greeCloudTestButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
const form = $('#settingsForm');
const resultBox = $('#greeCloudTestResult');
button.disabled = true;
if (resultBox) { resultBox.hidden = true; resultBox.classList.remove('success', 'error'); }
try {
await saveGreeCloudSettings(form);
const result = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
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');
}
if (result.ok) {
const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud);
applySettingsSection('greeCloud', refreshed);
renderSettings();
}
} catch (error) {
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.textContent = error.message; }
} finally { button.disabled = false; }
});
$('#greeCloudRefreshButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await saveGreeCloudSettings($('#settingsForm')); await loadCloudDiscovery(); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#cloudDiscoveryRefresh')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await loadCloudDiscovery({ open: false }); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});