This commit is contained in:
Mateusz Gruszczyński
2026-08-23 23:32:26 +02:00
parent b23578a0c8
commit f569b7db14
42 changed files with 597 additions and 4104 deletions
+167 -35
View File
@@ -16,7 +16,7 @@ const app = {
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
languages: [], translations: {}, locales: {}, historyReadings: [], historyZone: 'all',
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -28,7 +28,8 @@ const tr = (key, params = {}) => {
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
};
const fmtTemp = value => Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value);
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
@@ -59,7 +60,7 @@ function applyTranslations() {
if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`);
renderAll();
if (app.currentView === 'logs') loadLogs();
if (app.currentView === 'history' && app.devices.length) loadHistory();
if (app.currentView === 'history' && app.zones.length) loadHistory();
}
function setLanguage(language) {
@@ -340,8 +341,12 @@ function fillSelects() {
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
const presetZone = $('#schedulePresetZone');
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
const history = $('#historyDevice');
if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; }
const history = $('#historyZone');
if (history) {
const historyCurrent = history.value || 'all';
history.innerHTML = `<option value="all">${esc(tr('history.allZones'))}</option>${zoneOptions}`;
if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent;
}
}
function renderAccessTokens() {
@@ -383,7 +388,7 @@ function showView(name) {
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name))));
window.scrollTo({top: 0, behavior: 'smooth'});
if (name === 'history' && app.devices.length) loadHistory();
if (name === 'history' && app.zones.length) loadHistory();
if (name === 'logs') loadLogs();
}
@@ -492,47 +497,172 @@ function populateAutomation(id) {
}
async function loadHistory() {
const deviceId = $('#historyDevice').value;
if (!deviceId) return drawChart([]);
const zoneId = $('#historyZone')?.value || 'all';
const hours = $('#historyHours')?.value || '24';
try {
const data = await api(`/api/readings?device_id=${encodeURIComponent(deviceId)}&hours=${encodeURIComponent($('#historyHours').value)}&limit=2500`);
drawChart(data.readings || []);
const data = await api(`/api/history?zone_id=${encodeURIComponent(zoneId)}&hours=${encodeURIComponent(hours)}&limit=12000`);
const readings = data.readings || [];
app.historyReadings = readings; app.historyZone = zoneId;
renderHistorySummary(readings, zoneId);
drawHistoryCharts(readings, zoneId);
} catch (error) { toast(error.message, true); }
}
function drawChart(readings) {
const canvas = $('#historyChart');
const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue'];
function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function latestByZone(readings) {
const map = new Map();
readings.forEach(row => map.set(row.zone_id, row));
return map;
}
function renderHistorySummary(readings, zoneId) {
const host = $('#historySummary'); if (!host) return;
if (!readings.length) {
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('history.noData'))}</strong></div>`;
return;
}
if (zoneId === 'all') {
const latest = latestByZone(readings);
host.innerHTML = app.zones.filter(z => latest.has(z.id)).map(zone => {
const row = latest.get(zone.id);
return `<div class="history-stat"><small>${esc(zone.name)}</small><strong>${fmtTemp(row.control_temperature)}</strong><span>${esc(tr('history.targetShort'))} ${fmtTemp(row.target_temperature)}</span></div>`;
}).join('');
return;
}
const row = readings[readings.length - 1];
const cards = [
[tr('history.greeSensor'), fmtTemp(row.gree_temperature), tr('history.sensor')],
[tr('history.roomSensor'), fmtTemp(row.external_temperature), tr('history.homeAssistant')],
[tr('history.controlTemperature'), fmtTemp(row.control_temperature), row.control_source || '—'],
[tr('history.comfortTarget'), fmtTemp(row.target_temperature), row.active_preset || '—'],
[tr('history.deviceSetpoint'), fmtTemp(row.device_setpoint), (row.mode || '—').toUpperCase()],
[tr('history.outdoorTemperature'), fmtTemp(row.outdoor_temperature), tr('history.assist')],
];
host.innerHTML = cards.map(([label,value,detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
}
function timeLabel(ts, hours) {
const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'};
return new Date(ts).toLocaleString(locale(), options);
}
function prepareCanvas(canvas, height) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(680, Math.floor(rect.width || 680));
const height = 340;
const width = Math.max(720, Math.floor(rect.width || 720));
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr; canvas.height = height * dpr;
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);
const styles = getComputedStyle(document.documentElement);
const text = styles.getPropertyValue('--muted').trim(), grid = styles.getPropertyValue('--grid').trim(), indoor = styles.getPropertyValue('--accent').trim(), target = styles.getPropertyValue('--warning').trim();
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,width,height);
const pad = {left:52,right:20,top:22,bottom:40};
if (!readings.length) { ctx.fillStyle = text; ctx.font = '14px system-ui'; ctx.textAlign='center'; ctx.fillText(tr('history.noData'), width/2, height/2); return; }
const values = readings.flatMap(r => [r.indoor_temperature, r.target_temperature]).filter(Number.isFinite);
let min = Math.floor(Math.min(...values) - 1), max = Math.ceil(Math.max(...values) + 1); if (max-min < 4) { min -= 2; max += 2; }
const x = i => pad.left + i / Math.max(1, readings.length-1) * (width-pad.left-pad.right);
return {ctx,width,height};
}
function drawEmptyChart(canvas, height=340) {
const {ctx,width} = prepareCanvas(canvas,height);
ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center';
ctx.fillText(tr('history.noData'), width/2, height/2);
}
function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) {
if (!canvas || !rows.length || !series.length) return drawEmptyChart(canvas, height);
const {ctx,width} = prepareCanvas(canvas,height);
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'), panel=cssColor('--surface','#111');
const pad={left:54,right:20,top:20,bottom:42};
const allValues=[];
series.forEach(s => rows.forEach(r => { const v=s.value(r); if(Number.isFinite(v)) allValues.push(v); }));
if (!allValues.length) return drawEmptyChart(canvas,height);
let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue;
let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue;
if (max-min < 2) { min-=1; max+=1; }
const firstTs = new Date(rows[0].timestamp).getTime();
const lastTs = new Date(rows[rows.length-1].timestamp).getTime();
const span = Math.max(1,lastTs-firstTs);
const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right);
const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom);
ctx.lineWidth = 1; ctx.font = '11px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid;
for (let i=0;i<=5;i++) { const value = min+(max-min)*i/5, py=y(value); ctx.beginPath(); ctx.moveTo(pad.left,py); ctx.lineTo(width-pad.right,py); ctx.stroke(); ctx.textAlign='right'; ctx.fillText(`${value.toFixed(1)}°`,pad.left-8,py+4); }
const ticks = Math.min(5, readings.length-1);
for (let i=0;i<=ticks;i++) { const idx=Math.round(i*(readings.length-1)/Math.max(1,ticks)), px=x(idx); ctx.textAlign='center'; ctx.fillText(new Date(readings[idx].timestamp).toLocaleString(locale(),{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),px,height-13); }
function line(field, color, dashed=false) {
ctx.beginPath(); ctx.strokeStyle=color; ctx.lineWidth=2.3; ctx.setLineDash(dashed?[6,5]:[]); let started=false;
readings.forEach((row,index)=>{ const value=Number(row[field]); if(!Number.isFinite(value)) return; if(!started){ctx.moveTo(x(index),y(value));started=true;}else ctx.lineTo(x(index),y(value)); }); ctx.stroke(); ctx.setLineDash([]);
ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1;
for(let i=0;i<=5;i++){
const value=min+(max-min)*i/5, py=y(value);
ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke();
ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3);
}
line('target_temperature', target, true); line('indoor_temperature', indoor, false);
canvas._readings = readings;
const ticks=5;
for(let i=0;i<=ticks;i++){
const idx=Math.min(rows.length-1,Math.round(i*(rows.length-1)/ticks)); const px=x(rows[idx]);
ctx.textAlign='center'; ctx.fillText(timeLabel(rows[idx].timestamp,$('#historyHours')?.value),px,height-14);
}
series.forEach(s => {
ctx.beginPath(); ctx.strokeStyle=s.color; ctx.lineWidth=s.width||2; ctx.setLineDash(s.dash||[]);
let started=false, prev=null;
rows.forEach(row => {
const value=s.value(row); if(!Number.isFinite(value)) return;
const px=x(row),py=y(value);
if(!started||prev===null){ctx.moveTo(px,py);started=true;}
else if(s.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
prev=value;
});
ctx.stroke(); ctx.setLineDash([]);
});
canvas._history={rows,series};
}
function renderLegend(host, series) {
if (!host) return;
host.innerHTML=series.map(s=>`<span><i class="legend-line" style="--legend-color:${esc(s.color)}"></i>${esc(s.label)}</span>`).join('');
}
function drawHistoryCharts(readings, zoneId) {
const tempCanvas=$('#historyTemperatureChart'), opCanvas=$('#historyOperationChart');
if (!readings.length) {
drawEmptyChart(tempCanvas,360); drawEmptyChart(opCanvas,260);
$('#historyTemperatureLegend').innerHTML=''; $('#historyOperationLegend').innerHTML=''; return;
}
if (zoneId === 'all') {
const zones=app.zones.filter(z=>readings.some(r=>r.zone_id===z.id));
const rows=readings;
const tempSeries=zones.map((zone,index)=>({
label:zone.name,
color:cssColor(HISTORY_COLORS[index%HISTORY_COLORS.length],`hsl(${index*67%360} 70% 55%)`),
value:r=>r.zone_id===zone.id?historyNumber(r.control_temperature):NaN,
}));
const outdoorColor=cssColor('--muted-strong','#999');
tempSeries.push({label:tr('history.outdoorTemperature'),color:outdoorColor,dash:[6,5],value:r=>historyNumber(r.outdoor_temperature)});
drawLineChart(tempCanvas,tempSeries,rows,{height:360}); renderLegend($('#historyTemperatureLegend'),tempSeries);
const targetSeries=zones.map((zone,index)=>({
label:`${zone.name} · ${tr('history.targetShort')}`,
color:cssColor(HISTORY_COLORS[index%HISTORY_COLORS.length],`hsl(${index*67%360} 70% 55%)`),
dash:[5,4], value:r=>r.zone_id===zone.id?historyNumber(r.target_temperature):NaN,
}));
drawLineChart(opCanvas,targetSeries,rows,{height:260}); renderLegend($('#historyOperationLegend'),targetSeries);
return;
}
const temperatureSeries=[
{label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:r=>historyNumber(r.gree_temperature)},
{label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:r=>historyNumber(r.external_temperature)},
{label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:r=>historyNumber(r.control_temperature)},
{label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:r=>historyNumber(r.target_temperature)},
{label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:r=>historyNumber(r.device_setpoint)},
{label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:r=>historyNumber(r.outdoor_temperature)},
];
drawLineChart(tempCanvas,temperatureSeries,readings,{height:360}); renderLegend($('#historyTemperatureLegend'),temperatureSeries);
const operationSeries=[
{label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:r=>historyNumber(r.fan_speed)},
{label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:r=>r.demand?4.5:0.5},
{label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:r=>r.power?3.5:0.5},
];
drawLineChart(opCanvas,operationSeries,readings,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#historyOperationLegend'),operationSeries);
}
function drawCurrentChartIfVisible() {
const canvas = $('#historyChart');
if (app.currentView === 'history' && canvas?._readings) drawChart(canvas._readings);
if (app.currentView === 'history') drawHistoryCharts(app.historyReadings || [], app.historyZone || 'all');
}
async function loadLogs() {
@@ -638,7 +768,8 @@ $('#discoverButton').addEventListener('click', () => {
openDialog('discoverDialog');
});
$('#historyRefresh').addEventListener('click', loadHistory);
$('#historyDevice').addEventListener('change', loadHistory);
$('#historyZone').addEventListener('change', loadHistory);
$('#historyHours').addEventListener('change', loadHistory);
$('#historyHours').addEventListener('change', loadHistory);
$('#logsRefresh').addEventListener('click', loadLogs);
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
@@ -768,7 +899,8 @@ $('#haTest').addEventListener('click', async () => {
} catch(error){toast(error.message,true);}
});
window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); });
let historyResizeTimer;
window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } });
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); });
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{}));