v0.4.2
This commit is contained in:
+167
-35
@@ -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(()=>{}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user