function outdoorHistorySeries(devices, rows) { const selectedIds = new Set((devices || []).map(device => device.id)); const handled = new Set(); const definitions = []; (app.deviceGroups || []).forEach(group => { if (!group.outdoor_temperature_device_id) return; const members = (group.device_ids || []).filter(id => selectedIds.has(id)); if (!members.length) return; const representative = members.includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : members[0]; if (!rows.some(row => row.device_id === representative && Number.isFinite(historyNumber(row.outdoor_temperature)))) return; members.forEach(id => handled.add(id)); definitions.push({ representative, label: `${group.name} · ${tr('history.sharedOutdoor')}` }); }); (devices || []).filter(device => !handled.has(device.id)).forEach(device => { if (rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))) { definitions.push({ representative: device.id, label: device.name }); } }); return definitions.map((item, index) => ({ label: item.label, color: historySeriesColor(index), value: row => row.device_id === item.representative ? historyNumber(row.outdoor_temperature) : NaN, })); } function renderHistorySummary() { const host = $('#historySummary'); if (!host) return; if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; } if (app.historyTab === 'network') { renderNetworkHistorySummary(); 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')], [tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')], [tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')], ]; host.innerHTML = cards.map(([label, value, detail]) => `
${esc(label)}${esc(value)}${esc(detail)}
`).join(''); } function renderOverviewHistory() { const host = $('#historyCharts'); host.innerHTML = historyChartMarkup('overviewIndoorChart', tr('history.allGreeIndoor'), tr('history.allGreeIndoorHint')) + historyChartMarkup('overviewOutdoorChart', tr('history.allOutdoor'), tr('history.allOutdoorHint')) + historyChartMarkup('overviewZonesChart', tr('history.allZoneControl'), tr('history.allZoneControlHint')); const deviceRows = app.historyData.devices; const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN })); drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries); const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ ...item, label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}` })); const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))]; const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN })); const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')]; const outdoorSeries = [...outdoorDeviceSeries, ...outdoorHaSeries]; drawLineChart($('#overviewOutdoorChart'), outdoorSeries, outdoorRows, { height: 320 }); renderLegend($('#overviewOutdoorChartLegend'), outdoorSeries); const zoneRows = app.historyData.zones; const zoneSeries = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN })); drawLineChart($('#overviewZonesChart'), zoneSeries, zoneRows, { height: 340 }); renderLegend($('#overviewZonesChartLegend'), zoneSeries); } function renderZoneHistory() { const selected = app.historyZone; const rows = selected === 'all' ? app.historyData.zones : app.historyData.zones.filter(row => row.zone_id === selected); const host = $('#historyCharts'); host.innerHTML = historyChartMarkup('zoneTemperatureChart', tr('history.temperatureOverview'), tr('history.temperatureOverviewHint')) + historyChartMarkup('zoneOperationChart', tr('history.operationOverview'), tr('history.operationOverviewHint'), true); if (selected === 'all') { const series = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN })); drawLineChart($('#zoneTemperatureChart'), series, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), series); const targets = app.zones.map((zone, index) => ({ label: `${zone.name} · ${tr('history.targetShort')}`, color: historySeriesColor(index), dash: [5, 4], value: row => row.zone_id === zone.id ? historyNumber(row.target_temperature) : NaN })); drawLineChart($('#zoneOperationChart'), targets, rows, { height: 260 }); renderLegend($('#zoneOperationChartLegend'), targets); return; } const temperatureSeries = [ { label: tr('history.greeSensor'), color: cssColor('--accent', '#3ecf8e'), value: row => historyNumber(row.gree_temperature) }, { label: tr('history.roomSensor'), color: cssColor('--info', '#60a5fa'), value: row => historyNumber(row.external_temperature) }, { label: tr('history.controlTemperature'), color: cssColor('--teal', '#2dd4bf'), width: 2.8, value: row => historyNumber(row.control_temperature) }, { label: tr('history.comfortTarget'), color: cssColor('--warning', '#f59e0b'), dash: [7, 5], value: row => historyNumber(row.target_temperature) }, { label: tr('history.deviceSetpoint'), color: cssColor('--purple', '#a78bfa'), dash: [3, 4], value: row => historyNumber(row.device_setpoint) }, { label: tr('history.outdoorTemperature'), color: cssColor('--muted-strong', '#9ca3af'), dash: [2, 5], value: row => historyNumber(row.outdoor_temperature) }, ]; drawLineChart($('#zoneTemperatureChart'), temperatureSeries, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), temperatureSeries); const operationSeries = [ { label: tr('history.fanSpeed'), color: cssColor('--info', '#60a5fa'), step: true, value: row => historyNumber(row.fan_speed), tooltipValue: value => fanLabel(Math.round(value)) }, { label: tr('history.demand'), color: cssColor('--accent', '#3ecf8e'), step: true, width: 2.4, value: row => row.demand ? 4.5 : 0.5, tooltipValue: (_, row) => row.demand ? tr('common.active') : tr('common.disabled') }, { label: tr('history.power'), color: cssColor('--warning', '#f59e0b'), step: true, dash: [5, 4], value: row => row.power ? 3.5 : 0.5, tooltipValue: (_, row) => row.power ? tr('common.on') : tr('common.off') }, ]; drawLineChart($('#zoneOperationChart'), operationSeries, rows, { height: 260, minValue: 0, maxValue: 5, binaryLabels: true }); renderLegend($('#zoneOperationChartLegend'), operationSeries); } function renderDeviceHistory() { const selected = app.historyDevice; const rows = selected === 'all' ? app.historyData.devices : app.historyData.devices.filter(row => row.device_id === selected); const host = $('#historyCharts'); host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true); const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected); const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN })); const outdoor = outdoorHistorySeries(devices, rows); const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN })); drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor); drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor); drawLineChart($('#deviceTargetChart'), targets, rows, { height: 260 }); renderLegend($('#deviceTargetChartLegend'), targets); } function renderSensorHistory() { const selected = app.historySensor; const rows = selected === 'all' ? app.historyData.sensors : app.historyData.sensors.filter(row => row.entity_id === selected); const entities = [...new Set(rows.map(row => row.entity_id))]; const host = $('#historyCharts'); host.innerHTML = historyChartMarkup('haSensorsChart', tr('history.haSensors'), tr('history.haSensorsHint')); const series = entities.map((entity, index) => ({ label: haSensorLabel(entity), color: historySeriesColor(index), dash: rows.some(row => row.entity_id === entity && row.kind === 'outdoor') ? [6, 4] : [], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN })); drawLineChart($('#haSensorsChart'), series, rows, { height: 370 }); renderLegend($('#haSensorsChartLegend'), series); } function networkTargetRows() { const selected = app.historyNetworkTarget || 'all'; return selected === 'all' ? (app.historyNetwork || []) : (app.historyNetwork || []).filter(row => row.target_id === selected); } function networkTargetLabel(id) { return (app.historyNetworkTargets || []).find(target => target.id === id)?.name || id; } function renderNetworkHistorySummary() { const host = $('#historySummary'); if (!host) return; const rows = networkTargetRows(); const ids = [...new Set(rows.map(row => row.target_id))]; if (!ids.length) { host.innerHTML = ''; return; } host.innerHTML = ids.map(id => { const latest = rows.filter(row => row.target_id === id).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]; const latency = Number.isFinite(Number(latest?.latency_ms)) ? `${Number(latest.latency_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—'; const jitter = Number.isFinite(Number(latest?.jitter_ms)) ? `${Number(latest.jitter_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—'; const loss = `${Number(latest?.packet_loss_pct || 0).toLocaleString(locale(), { maximumFractionDigits: 1 })}%`; return `
${esc(networkTargetLabel(id))}${esc(latency)}${esc(tr('history.networkJitter'))}: ${esc(jitter)} · ${esc(tr('history.networkLoss'))}: ${esc(loss)} · ${esc(latest?.successful_samples ?? 0)}/${esc(latest?.sample_count ?? 0)}
`; }).join(''); } function renderNetworkHistory() { const host = $('#historyCharts'); if (!host) return; renderNetworkHistorySummary(); const rows = networkTargetRows(); if (!rows.length) { host.innerHTML = `
${esc(tr('history.networkNoData'))}${esc(tr('history.networkNoDataHint'))}
`; return; } const ids = [...new Set(rows.map(row => row.target_id))]; const latencySeries = []; const lossSeries = []; ids.forEach((id, index) => { const label = networkTargetLabel(id); const color = historySeriesColor(index); latencySeries.push({ key: `${id}:latency`, label: `${label} · ${tr('history.networkLatency')}`, color, value: row => row.target_id === id ? historyNumber(row.latency_ms) : NaN, tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`, }); if (app.historyNetworkShowJitter !== false) latencySeries.push({ key: `${id}:jitter`, label: `${label} · ${tr('history.networkJitter')}`, color, dash: [7, 5], value: row => row.target_id === id ? historyNumber(row.jitter_ms) : NaN, tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`, }); lossSeries.push({ key: `${id}:loss`, label, color, value: row => row.target_id === id ? historyNumber(row.packet_loss_pct) : NaN, tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })}%`, }); }); host.innerHTML = historyChartMarkup('networkLatencyChart', tr('history.networkLatencyJitter'), tr('history.networkLatencyJitterHint')) + historyChartMarkup('networkLossChart', tr('history.networkLoss'), tr('history.networkLossHint')); drawLineChart($('#networkLatencyChart'), latencySeries, rows, { height: 360, minValue: 0, axisSuffix: ' ms', tooltipUnit: ' ms', axisDigits: 0 }); renderLegend($('#networkLatencyChartLegend'), latencySeries); drawLineChart($('#networkLossChart'), lossSeries, rows, { height: 300, minValue: 0, maxValue: 100, axisSuffix: '%', tooltipUnit: '%', axisDigits: 0 }); renderLegend($('#networkLossChartLegend'), lossSeries); } function customSeriesOptions() { const items = []; const groupedOutdoorIds = new Set(); (app.deviceGroups || []).forEach(group => { if (!group.outdoor_temperature_device_id) return; const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : (group.device_ids || [])[0]; if (!representative) return; (group.device_ids || []).forEach(id => groupedOutdoorIds.add(id)); items.push([`installation|${group.id}|outdoor`, `${group.name} · ${tr('history.sharedOutdoor')}`]); }); app.devices.forEach(device => { items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]); if (!groupedOutdoorIds.has(device.id)) items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]); items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]); }); app.zones.forEach(zone => { items.push([`zone|${zone.id}|control`, `${zone.name} · ${tr('history.controlTemperature')}`]); items.push([`zone|${zone.id}|gree`, `${zone.name} · ${tr('history.greeSensor')}`]); items.push([`zone|${zone.id}|external`, `${zone.name} · ${tr('history.roomSensor')}`]); items.push([`zone|${zone.id}|target`, `${zone.name} · ${tr('history.comfortTarget')}`]); items.push([`zone|${zone.id}|device_target`, `${zone.name} · ${tr('history.deviceSetpoint')}`]); items.push([`zone|${zone.id}|outdoor`, `${zone.name} · ${tr('history.outdoorTemperature')}`]); }); [...new Set(app.historyData.sensors.map(row => row.entity_id))].forEach(entity => items.push([`ha|${entity}|temperature`, `HA · ${haSensorLabel(entity)}`])); return items; } function customSeriesDefinition(key, index = 0) { const [kind, id, field] = String(key).split('|'); const color = historySeriesColor(index); if (kind === 'device') { const device = app.devices.find(item => item.id === id); if (!device) return null; const labels = { indoor: tr('history.indoorTemperature'), outdoor: tr('history.greeOutdoor'), target: tr('history.deviceTarget') }; const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' }; return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN }; } if (kind === 'installation' && field === 'outdoor') { const group = (app.deviceGroups || []).find(item => item.id === id); if (!group) return null; const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : (group.device_ids || [])[0]; if (!representative) return null; return { key, label: `${group.name} · ${tr('history.sharedOutdoor')}`, color, value: row => !row.zone_id && !row.entity_id && row.device_id === representative ? historyNumber(row.outdoor_temperature) : NaN }; } if (kind === 'zone') { const zone = app.zones.find(item => item.id === id); if (!zone) return null; const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] }; const info = fields[field]; if (!info) return null; return { key, label: `${zone.name} · ${info[1]}`, color, dash: ['target', 'device_target', 'outdoor'].includes(field) ? [6, 4] : [], value: row => row.zone_id === id ? historyNumber(row[info[0]]) : NaN }; } if (kind === 'ha') return { key, label: `HA · ${haSensorLabel(id)}`, color, dash: [3, 4], value: row => row.entity_id === id ? historyNumber(row.temperature) : NaN }; return null; } function persistSavedCharts() { localStorage.setItem('gree_controller_saved_charts', JSON.stringify(app.savedCharts.slice(0, 30))); } function renderCustomBuilder() { const host = $('#historyCustomBuilder'); if (!host) return; if (app.historyTab !== 'custom') { host.innerHTML = ''; host.classList.remove('active'); return; } host.classList.add('active'); const options = customSeriesOptions(); const selected = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean); host.innerHTML = `

${esc(tr('history.customTitle'))}

${esc(tr('history.customDescription'))}

${selected.length ? selected.map((item, index) => `${esc(item.label)}`).join('') : `${esc(tr('history.noCustomSeries'))}`}
${app.savedCharts.length ? app.savedCharts.map(item => `
`).join('') : `${esc(tr('history.noSavedCharts'))}`}
`; } function renderCustomHistory() { renderCustomBuilder(); const host = $('#historyCharts'); host.innerHTML = historyChartMarkup('customHistoryChart', tr('history.customChart'), tr('history.customChartHint')); const series = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean); const rows = [...app.historyData.devices, ...app.historyData.zones, ...app.historyData.sensors]; drawLineChart($('#customHistoryChart'), series, rows, { height: 390 }); renderLegend($('#customHistoryChartLegend'), series); } function renderHistoryPage() { renderHistorySummary(); renderCustomBuilder(); 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 === 'network') renderNetworkHistory(); else if (app.historyTab === 'sensors') renderSensorHistory(); else renderCustomHistory(); } function drawCurrentChartIfVisible() { if (app.currentView === 'history') renderHistoryPage(); } async function handleHistoryAction(button) { const action = button.dataset.historyAction; if (action === 'add-series') { const value = $('#customSeriesSelect')?.value; if (value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value); renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; } if (action === 'remove-series') { app.customChartSeries.splice(Number(button.dataset.index), 1); renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; } if (action === 'save-chart') { if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); const name = $('#customChartName')?.value.trim() || tr('history.customChart'); const item = { id: `chart-${Date.now()}`, name, series: [...app.customChartSeries], hours: $('#historyHours')?.value || '24' }; app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return; } if (action === 'load-chart') { const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return; app.customChartSeries = [...item.series]; if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours); renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return; } if (action === 'delete-chart') { app.savedCharts = app.savedCharts.filter(entry => entry.id !== button.dataset.id); persistSavedCharts(); renderCustomHistory(); return; } if (action === 'copy-chart-link') { if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); const path = currentHistoryPath(); updateBrowserUrl(path, true); const link = `${location.origin}${APP_BASE}${path}`; try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); } toast(tr('history.linkCopied')); return; } } async function loadEnergyHistory() { const targets = normalizeEnergyHistoryTargets(); if (!targets.length || !app.historyEnergyTargets.length) { app.historyEnergy = []; return; } const hours = Number($('#historyHours')?.value || 24); const days = Math.max(1, Math.ceil(hours / 24)); 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 || []).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]) => `
${esc(label)}${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}
`).join(''); return; } host.innerHTML = data.map(item => `
${esc(item.target_name || item.target_id)}${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh${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` : ''}
`).join(''); } function renderEnergyHistory() { const host = $('#historyCharts'); if (!host) return; const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none'); renderEnergyHistorySummary(); if (!data.length) { host.innerHTML = `
${esc(tr('energy.noData'))}
`; 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'), series, { height: 360, interval: app.historyEnergyInterval }); }