This commit is contained in:
Mateusz Gruszczyński
2026-08-30 23:00:29 +02:00
parent 6465ba1125
commit 4054fe3f76
32 changed files with 3722 additions and 3272 deletions
+16
View File
@@ -0,0 +1,16 @@
# Frontend JavaScript sources
`build.rs` concatenates the files listed in `APP_JS_MODULES` into one generated `app.bundle.js` in Cargo's `OUT_DIR`.
The server exposes that bundle as `/app-<content-hash>.js`, so the browser downloads one application script and can cache it immutably.
Source responsibilities:
- `router.js` owns application URLs, browser history, route parsing and URL-to-view mapping.
- `navigation.js` owns view/tab UI transitions and navigation-related controls.
- the remaining files are split by feature or shared responsibility.
The Rust HTTP router serves `index.html` only for the explicitly supported SPA routes. Unknown paths return the standalone `web/404.html` page with HTTP `404` instead of silently opening the dashboard.
`web/app.js` is intentionally not used and should not exist. Do not recreate it manually; the bundle is generated at build time.
Keep the module order in `build.rs` explicit. These files intentionally share one application scope and are bundled before being served to the browser.
+29
View File
@@ -0,0 +1,29 @@
async function loadBootstrap(showMessage = false) {
if (app.loading) return;
app.loading = true;
try {
const data = await api('/api/bootstrap');
app.devices = data.devices || [];
app.zones = data.zones || [];
app.groups = data.groups || [];
app.schedules = data.schedules || [];
app.automations = data.automations || [];
app.accessTokens = data.access_tokens || [];
app.settings = data.settings || null;
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
app.system = data.system || {};
app.systemSnapshotAt = Date.now();
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
renderAll();
loadControlPlan();
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
if (showMessage) toast(tr('common.updated'));
if ($('#tokenDialog').open) $('#tokenDialog').close();
connectWebSocket();
} catch (error) {
if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true);
} finally {
app.loading = false;
}
}
+454
View File
@@ -0,0 +1,454 @@
function encodeChartSpec(series) {
try {
const raw = JSON.stringify(series || []);
return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
} catch (_) { return ''; }
}
function decodeChartSpec(encoded) {
try {
const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
const value = JSON.parse(atob(padded));
return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : [];
} catch (_) { return []; }
}
function historyEntityOptions() {
const zones = app.zones.map(zone => `<option value="${esc(zone.id)}">${esc(zone.name)}</option>`).join('');
const devices = app.devices.map(device => `<option value="${esc(device.id)}">${esc(device.name)}</option>`).join('');
const entities = [...new Set([
...app.historyData.sensors.map(row => row.entity_id),
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
app.settings?.home_assistant?.outdoor_entity_id,
].filter(Boolean))].sort();
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).join('');
return {zones, devices, sensors, entities};
}
function renderHistoryNavigation() {
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
const host = $('#historyContextControls'); if (!host) return;
const options = historyEntityOptions();
if (app.historyTab === 'zones') {
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone;
} 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 === '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;
} else if (app.historyTab === 'custom') {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.customHint'))}</span>`;
} else {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
}
}
async function loadHistory() {
if (app.historyLoading) return;
app.historyLoading = true;
const hours = $('#historyHours')?.value || '24';
try {
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 || {};
renderHistoryNavigation();
renderHistoryPage();
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
} catch (error) {
toast(error.message, true);
renderHistoryPage();
} finally { app.historyLoading = false; }
}
const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue'];
const MAX_CHART_ZOOM = 16;
const chartRuntime = new Map();
function chartSeriesKey(item, index) {
return item.key || `${index}:${item.label}`;
}
function isChartSeriesHidden(chartId, item, index) {
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item,index)];
}
function setChartSeriesHidden(chartId, item, index, hidden) {
if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {};
const key = chartSeriesKey(item,index);
if (hidden) app.chartHiddenSeries[chartId][key] = true;
else delete app.chartHiddenSeries[chartId][key];
}
function updateChartZoomControls(id) {
const canvas=document.getElementById(id);
const card=canvas?.closest('.history-chart-card');
if(!card) return;
const zoom=clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM);
const reset=card.querySelector('[data-chart-zoom="reset"]');
const out=card.querySelector('[data-chart-zoom="out"]');
const zoomIn=card.querySelector('[data-chart-zoom="in"]');
if(reset) reset.textContent=`${Math.round(zoom*100)}%`;
if(out) out.disabled=zoom<=1;
if(zoomIn) zoomIn.disabled=zoom>=MAX_CHART_ZOOM;
}
function redrawHistoryChart(id) {
const runtime=chartRuntime.get(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);
updateChartZoomControls(id);
}
function setChartZoom(id, value, anchorRatio=null, anchorViewportX=null) {
const canvas=document.getElementById(id);
const wrap=canvas?.parentElement;
if(!canvas || !wrap) return;
const oldWidth=canvas.getBoundingClientRect().width || 1;
const ratio=Number.isFinite(anchorRatio) ? clamp(anchorRatio,0,1) : clamp((wrap.scrollLeft+wrap.clientWidth/2)/oldWidth,0,1);
const viewportX=Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth/2;
const next=clamp(Number(value) || 1,1,MAX_CHART_ZOOM);
app.chartZooms[id]=next;
redrawHistoryChart(id);
const nextCanvas=document.getElementById(id);
const nextWrap=nextCanvas?.parentElement;
if(!nextCanvas || !nextWrap) return;
const nextWidth=nextCanvas.getBoundingClientRect().width || 1;
const previousBehavior=nextWrap.style.scrollBehavior;
nextWrap.style.scrollBehavior='auto';
nextWrap.scrollLeft=Math.max(0,ratio*nextWidth-viewportX);
nextWrap.style.scrollBehavior=previousBehavior;
}
function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
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 wrapWidth = Math.floor(canvas.parentElement?.clientWidth || rect.width || 720);
const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4);
const width = Math.max(720, Math.floor(Math.max(720, wrapWidth) * zoom));
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.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,width,height);
return {ctx,width,height};
}
function drawEmptyChart(canvas, height=340) {
if (!canvas) return;
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);
const wrap=canvas.parentElement;
const tooltip=wrap?.querySelector('.chart-tooltip');
const line=wrap?.querySelector('.chart-hover-line');
if(tooltip) tooltip.hidden=true;
if(line) line.hidden=true;
canvas.onpointermove=null; canvas.onpointerleave=null; canvas.onpointerdown=null; canvas.onpointerup=null; canvas.onpointercancel=null; canvas.onkeydown=null;
}
function chartPreciseTime(timestamp) {
return new Intl.DateTimeFormat(locale(), {
year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit'
}).format(new Date(timestamp));
}
function nearestPoint(points, targetTs) {
if (!points.length) return null;
let lo=0, hi=points.length-1;
while(lo<hi){
const mid=Math.floor((lo+hi)/2);
if(points[mid].ts < targetTs) lo=mid+1; else hi=mid;
}
const after=points[lo];
const before=lo>0?points[lo-1]:null;
if(!before) return after;
return Math.abs(before.ts-targetTs) <= Math.abs(after.ts-targetTs) ? before : after;
}
function formatChartTooltipValue(item, point, binaryLabels) {
if (!point || !Number.isFinite(point.value)) return '—';
if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row));
const formatted = Number(point.value).toLocaleString(locale(), {minimumFractionDigits:1, maximumFractionDigits:2});
return binaryLabels ? formatted : `${formatted} °C`;
}
function bindChartTooltip(canvas, series, sortedRows, geometry) {
const wrap=canvas?.parentElement;
const tooltip=wrap?.querySelector('.chart-tooltip');
const line=wrap?.querySelector('.chart-hover-line');
const selection=wrap?.querySelector('.chart-selection');
if(!canvas || !wrap || !tooltip || !line) return;
const {pad,width,height,firstTs,lastTs,binaryLabels}=geometry;
const plotWidth=width-pad.left-pad.right;
const timestamps=[...new Set(sortedRows.map(row=>new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a,b)=>a-b);
const timestampPoints=timestamps.map(ts=>({ts,value:ts}));
const pointsBySeries=series.map(item=>({
item,
points:sortedRows.map(row=>({row,ts:new Date(row.timestamp).getTime(),value:item.value(row)}))
.filter(point=>Number.isFinite(point.ts)&&Number.isFinite(point.value))
.sort((a,b)=>a.ts-b.ts)
}));
const touches=new Map();
let drag=null;
let pinch=null;
let touchPan=null;
const hide=()=>{ tooltip.hidden=true; line.hidden=true; };
const hideSelection=()=>{ if(selection) selection.hidden=true; };
const canvasX=clientX=>{
const rect=canvas.getBoundingClientRect();
return clamp(clientX-rect.left,pad.left,width-pad.right);
};
const showAtTimestamp=(targetTs)=>{
if(!timestamps.length) return hide();
const snapped=nearestPoint(timestampPoints,targetTs)?.ts;
if(!Number.isFinite(snapped)) return hide();
const px=pad.left + (snapped-firstTs)/Math.max(1,lastTs-firstTs)*plotWidth;
const values=pointsBySeries.map(({item,points})=>({item,point:nearestPoint(points,snapped)})).filter(entry=>entry.point);
if(!values.length) return hide();
$$('.chart-tooltip').forEach(node=>{ if(node!==tooltip) node.hidden=true; });
$$('.chart-hover-line').forEach(node=>{ if(node!==line) node.hidden=true; });
tooltip.innerHTML=`<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({item,point})=>{
const sampleTime=Math.abs(point.ts-snapped)>1000?`<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>`:'';
return `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(formatChartTooltipValue(item,point,binaryLabels))}${sampleTime}</span></div>`;
}).join('')}</div>`;
tooltip.hidden=false;
line.hidden=false;
line.style.left=`${px}px`;
line.style.top=`${pad.top}px`;
line.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`;
const minLeft=wrap.scrollLeft+8;
const maxLeft=wrap.scrollLeft+wrap.clientWidth-tooltip.offsetWidth-8;
let tooltipLeft=px+12;
if(tooltipLeft+tooltip.offsetWidth>wrap.scrollLeft+wrap.clientWidth-8) tooltipLeft=px-tooltip.offsetWidth-12;
tooltip.style.left=`${Math.max(minLeft,Math.min(tooltipLeft,Math.max(minLeft,maxLeft)))}px`;
tooltip.style.top=`${pad.top+8}px`;
canvas.dataset.chartHoverTs=String(snapped);
};
const showFromClientX=(clientX)=>{
const localX=canvasX(clientX);
const targetTs=firstTs + ((localX-pad.left)/Math.max(1,plotWidth))*(lastTs-firstTs);
showAtTimestamp(targetTs);
};
const updateSelection=(currentX)=>{
if(!drag || !selection) return;
const left=Math.min(drag.startX,currentX);
const right=Math.max(drag.startX,currentX);
drag.lastX=currentX;
drag.moved=Math.abs(right-left)>=7;
if(!drag.moved) return hideSelection();
selection.hidden=false;
selection.style.left=`${left}px`;
selection.style.width=`${Math.max(1,right-left)}px`;
selection.style.top=`${pad.top}px`;
selection.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`;
};
canvas.onpointerdown=event=>{
if(event.pointerType==='mouse'){
if(event.button!==0) return;
const startX=canvasX(event.clientX);
drag={pointerId:event.pointerId,startX,lastX:startX,moved:false};
hide(); hideSelection();
canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
return;
}
if(event.pointerType==='touch'){
touches.set(event.pointerId,{x:event.clientX,y:event.clientY});
if(touches.size===1){
touchPan={pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,scrollLeft:wrap.scrollLeft,moved:false};
showFromClientX(event.clientX);
}
if(touches.size===2){
touchPan=null;
const points=[...touches.values()];
const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y);
const midX=(points[0].x+points[1].x)/2;
const rect=canvas.getBoundingClientRect();
const wrapRect=wrap.getBoundingClientRect();
pinch={
startDistance:Math.max(1,distance),
startZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM),
baseWidth:(rect.width||width)/clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM),
anchorRatio:clamp((midX-rect.left)/Math.max(1,rect.width),0,1),
viewportX:clamp(midX-wrapRect.left,0,wrap.clientWidth),
previewZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM),
};
hide();
event.preventDefault();
}
}
};
canvas.onpointermove=event=>{
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){
updateSelection(canvasX(event.clientX));
event.preventDefault();
return;
}
if(event.pointerType==='touch' && touches.has(event.pointerId)){
touches.set(event.pointerId,{x:event.clientX,y:event.clientY});
if(pinch && touches.size>=2){
const points=[...touches.values()].slice(0,2);
const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y);
pinch.previewZoom=clamp(pinch.startZoom*(distance/pinch.startDistance),1,MAX_CHART_ZOOM);
const previewWidth=pinch.baseWidth*pinch.previewZoom;
canvas.style.width=`${previewWidth}px`;
wrap.scrollLeft=Math.max(0,pinch.anchorRatio*previewWidth-pinch.viewportX);
const reset=canvas.closest('.history-chart-card')?.querySelector('[data-chart-zoom="reset"]');
if(reset) reset.textContent=`${Math.round(pinch.previewZoom*100)}%`;
event.preventDefault();
} else if(touchPan?.pointerId===event.pointerId && touches.size===1){
const dx=event.clientX-touchPan.startX;
const dy=event.clientY-touchPan.startY;
if(Math.abs(dx)>6 && Math.abs(dx)>Math.abs(dy)){
touchPan.moved=true;
hide();
wrap.scrollLeft=Math.max(0,touchPan.scrollLeft-dx);
event.preventDefault();
}
}
return;
}
showFromClientX(event.clientX);
};
const finishPointer=event=>{
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){
const currentX=canvasX(event.clientX);
const selectedWidth=Math.abs(currentX-drag.startX);
const center=(currentX+drag.startX)/2;
const moved=drag.moved && selectedWidth>=12;
drag=null; hideSelection();
try{ canvas.releasePointerCapture?.(event.pointerId); }catch(_){}
if(moved){
const currentZoom=clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM);
const factor=Math.max(1,wrap.clientWidth/Math.max(1,selectedWidth));
setChartZoom(canvas.id,currentZoom*factor,center/Math.max(1,width),wrap.clientWidth/2);
}else showFromClientX(event.clientX);
return;
}
if(event.pointerType==='touch'){
const wasPan=touchPan?.pointerId===event.pointerId ? touchPan : null;
touches.delete(event.pointerId);
if(pinch && touches.size<2){
const completed=pinch;
pinch=null;
setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX);
} else if(wasPan && !wasPan.moved) showFromClientX(event.clientX);
if(touches.size===0) touchPan=null;
}
};
canvas.onpointerup=finishPointer;
canvas.onpointercancel=event=>{
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ drag=null; hideSelection(); }
if(event.pointerType==='touch'){
touches.delete(event.pointerId);
if(touchPan?.pointerId===event.pointerId) touchPan=null;
if(pinch && touches.size<2){
const completed=pinch;
pinch=null;
setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX);
} else hide();
}
};
canvas.onpointerleave=event=>{ if(event.pointerType!=='touch' && !drag) hide(); };
canvas.onkeydown=event=>{
if(!['ArrowLeft','ArrowRight'].includes(event.key) || !timestamps.length) return;
event.preventDefault();
const current=Number(canvas.dataset.chartHoverTs);
let index=Number.isFinite(current)?timestamps.findIndex(ts=>ts===current):-1;
if(index<0) index=event.key==='ArrowRight'?0:timestamps.length-1;
else index=clamp(index+(event.key==='ArrowRight'?1:-1),0,timestamps.length-1);
showAtTimestamp(timestamps[index]);
};
}
function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) {
if (!canvas) return;
const options={height,minValue,maxValue,binaryLabels};
if(canvas.id) chartRuntime.set(canvas.id,{series,rows,options});
const visibleSeries=series.filter((item,index)=>!isChartSeriesHidden(canvas.id,item,index));
if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height);
const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp));
const {ctx,width} = prepareCanvas(canvas,height);
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333');
const pad={left:54,right:20,top:20,bottom:42};
const allValues=[];
visibleSeries.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); }));
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(sortedRows[0].timestamp).getTime();
const lastTs = new Date(sortedRows[sortedRows.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.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);
}
const ticks=5;
for(let i=0;i<=ticks;i++){
const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]);
ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14);
}
visibleSeries.forEach(item => {
ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]);
let started=false, prev=null;
sortedRows.forEach(row => {
const value=item.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(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
prev=value;
});
ctx.stroke(); ctx.setLineDash([]);
});
bindChartTooltip(canvas,visibleSeries,sortedRows,{pad,width,height,firstTs,lastTs,binaryLabels});
}
function renderLegend(host, series) {
if (!host) return;
const chartId=host.id.replace(/Legend$/,'');
host.innerHTML=series.map((item,index)=>{
const hidden=isChartSeriesHidden(chartId,item,index);
const title=tr(hidden?'history.legendShow':'history.legendHide',{name:item.label});
return `<button type="button" class="legend-item${hidden?' is-hidden':''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden?'false':'true'}" title="${esc(title)}"><i class="legend-line" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
}).join('');
}
function historyChartMarkup(id, title, hint, compact=false) {
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
const percent = Math.round(zoom * 100);
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom<=1?'disabled':''}></button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom>=MAX_CHART_ZOOM?'disabled':''}>+</button></div></div><div class="chart-wrap ${compact?'compact-chart':''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact?300:420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
}
function historySeriesColor(index) {
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`);
}
+324
View File
@@ -0,0 +1,324 @@
'use strict';
const APP_BASE = (() => {
const src = document.currentScript?.src || '';
try {
const path = new URL(src, location.href).pathname;
const slash = path.lastIndexOf('/');
const filename = path.slice(slash + 1);
return /^app(?:-[a-f0-9]+)?\.js$/i.test(filename) ? path.slice(0, slash).replace(/\/$/, '') : '';
} catch (_) { return ''; }
})();
const withBase = path => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
const parseDecimal = value => { const normalized = String(value ?? '').trim().replace(',', '.'); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : NaN; };
const getCookie = name => {
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
};
const setCookie = (name, value) => {
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`;
};
const DEFAULT_LANGUAGE = 'en';
const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE;
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
const app = {
devices: [], zones: [], groups: [], 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: {},
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, zoneTemperatureTimers: {},
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
};
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB';
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 => 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 houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(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)) : '—';
const localResumeSeconds = value => {
const timestamp = value ? new Date(value).getTime() : NaN;
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null;
};
const formatCountdown = seconds => {
if (!Number.isFinite(seconds)) return '--:--';
const safe = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safe / 60);
const remainder = safe % 60;
return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
};
const formatExtendedCountdown = seconds => {
if (!Number.isFinite(seconds)) return '∞';
const safe = Math.max(0, Math.floor(seconds));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const remainder = safe % 60;
return hours > 0
? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`
: `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
};
function updateLocalThermostatCountdowns() {
$$('[data-local-resume-countdown]').forEach(node => {
const remaining = localResumeSeconds(node.dataset.localResumeCountdown);
node.textContent = remaining !== null && remaining > 0
? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)})
: tr('zones.localThermostatResuming');
});
}
function temporarySessionStatus(zone) {
const session = zone?.temporary_quick_thermostat;
if (!session) return null;
const now = Date.now();
const secondsUntil = value => {
const timestamp = value ? new Date(value).getTime() : NaN;
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null;
};
const safetyRemaining = secondsUntil(session.safety_expires_at);
const hardRemaining = secondsUntil(session.expires_at);
const startRemaining = secondsUntil(session.started_at);
const pending = !session.activated_at;
const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint);
const targetText = Number.isFinite(target) ? target.toFixed(1) : '--';
const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1);
const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort';
const condition = tr(operatorKey, {target:targetText, tolerance});
if (session.state === 'waiting_master') {
return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true};
}
if (session.state === 'paused_manual') {
return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending};
}
if (pending) {
return {
countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00',
detail:startRemaining != null && startRemaining > 0
? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)})
: tr('zones.temporaryStartingStatus'),
kind:session.finish_kind,
pending:true,
};
}
if (session.finish_kind === 'temperature_stable') {
const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN;
const holdSeconds = Number(session.hold_seconds || 0);
if (Number.isFinite(started)) {
const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000));
const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining);
return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind};
}
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind};
}
if (session.finish_kind === 'temperature_reached') {
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind};
}
const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus';
return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind};
}
function updateTemporaryThermostatCountdowns() {
$$('[data-temporary-countdown-zone]').forEach(node => {
const zone = app.zones.find(item => item.id === node.dataset.temporaryCountdownZone);
const status = temporarySessionStatus(zone);
if (!status) return;
node.textContent = status.countdown;
node.title = status.detail;
});
const dialog = $('#temporaryThermostatDialog');
if (dialog?.open) {
const zone = app.zones.find(item => item.id === $('#temporaryThermostatForm')?.elements.zone_id.value);
const status = temporarySessionStatus(zone);
if (status) {
$('#temporaryThermostatActiveCountdown').textContent = status.countdown;
$('#temporaryThermostatActiveDetail').textContent = status.detail;
const label = $('#temporaryThermostatActiveLabel');
if (label) label.textContent = tr(status.pending ? 'zones.temporaryScheduled' : 'zones.temporaryActive');
}
}
}
const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
function syncTopbarHeight() {
const topbar = $('.topbar');
if (!topbar) return;
document.documentElement.style.setProperty('--topbar-height', `${Math.ceil(topbar.getBoundingClientRect().height)}px`);
}
function observeTopbarHeight() {
const topbar = $('.topbar');
if (!topbar) return;
syncTopbarHeight();
if ('ResizeObserver' in window) {
const observer = new ResizeObserver(syncTopbarHeight);
observer.observe(topbar);
} else {
window.addEventListener('resize', syncTopbarHeight);
}
}
function updateConnectionIndicator(status) {
app.connectionStatus = status || 'connecting';
const node = $('#connectionLabel');
const label = tr(`status.${app.connectionStatus}`);
if (node) {
node.className = `connection-dot ${app.connectionStatus}`;
node.setAttribute('aria-label', label);
node.title = label;
}
const banner = $('#connectionBanner');
const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus);
document.body.classList.toggle('connection-lost', disconnected);
if (banner) {
banner.hidden = !disconnected;
const title = $('#connectionBannerTitle');
const text = $('#connectionBannerText');
if (title) title.textContent = label;
if (text) text.textContent = tr('status.offlineHint');
}
renderSystemInfo();
}
function updateToolbarControls() {
const languageLabel = $('#languageLabel');
if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase();
const themeSelect = $('#themeSelect');
const themeIcon = $('#themeIcon');
const icons = {system:'◐',light:'☀',dark:'☾'};
if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system;
if (themeSelect) {
themeSelect.value = app.theme;
const label = `${tr('controls.theme')}: ${tr(`theme.${app.theme}`)}`;
themeSelect.setAttribute('aria-label', label);
themeSelect.closest('.toolbar-picker')?.setAttribute('title', label);
}
}
function applyTheme() {
const resolved = app.theme === 'light' || app.theme === 'dark'
? app.theme
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
const meta = $('#themeColorMeta');
if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515';
updateToolbarControls();
drawCurrentChartIfVisible();
}
function applyTranslations() {
document.documentElement.lang = app.language;
$$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); });
$$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); });
$$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); });
$$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); });
$$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); });
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
$('#languageSelect').value = app.language;
updateToolbarControls();
updateConnectionIndicator(app.connectionStatus);
renderAll();
if (app.currentView === 'logs') loadLogs();
if (app.currentView === 'history' && app.zones.length) loadHistory();
}
function setLanguage(language) {
const available = app.languages.some(item => item.code === language);
app.language = available ? language : DEFAULT_LANGUAGE;
setCookie('gree_controller_language', app.language);
applyTranslations();
}
function renderLanguageOptions() {
const select = $('#languageSelect');
if (!select) return;
select.innerHTML = app.languages.map(item => {
const label = item.native_name || item.name || item.code.toUpperCase();
return `<option value="${esc(item.code)}">${esc(label)}</option>`;
}).join('');
select.value = app.language;
updateToolbarControls();
}
async function loadLanguages() {
try {
const response = await fetch(withBase('/lang/index.json'), {cache: 'no-cache'});
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
const manifest = await response.json();
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing');
const loaded = await Promise.all(languages.map(async item => {
const packResponse = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`), {cache: 'no-cache'});
if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`);
const pack = await packResponse.json();
return {item, pack};
}));
app.languages = loaded.map(({item}) => item);
app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}]));
app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code]));
app.language = app.languages.some(item => item.code === preferredLanguage)
? preferredLanguage
: (manifest.default || DEFAULT_LANGUAGE);
if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE;
renderLanguageOptions();
} catch (error) {
console.error('Unable to load language packs:', error);
app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}];
app.translations = {[DEFAULT_LANGUAGE]: {}};
app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'};
app.language = DEFAULT_LANGUAGE;
renderLanguageOptions();
}
}
function setTheme(theme) {
app.theme = ['system', 'light', 'dark'].includes(theme) ? theme : 'system';
setCookie('gree_controller_theme', app.theme);
applyTheme();
}
function cycleTheme() {
const modes = ['system', 'light', 'dark'];
const index = modes.indexOf(app.theme);
setTheme(modes[(index + 1) % modes.length]);
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
headers.set('Accept', 'application/json');
if (app.token) headers.set('Authorization', `Bearer ${app.token}`);
let body = options.body;
if (body !== undefined && body !== null && typeof body !== 'string') {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(body);
}
const response = await fetch(withBase(path), {...options, headers, body});
if (response.status === 401) {
showTokenDialog();
throw new Error(tr('auth.invalid'));
}
if (!response.ok) {
let message = tr('error.http', {status: response.status});
try { message = (await response.json()).error || message; } catch (_) {}
throw new Error(message);
}
if (response.status === 204) return null;
return response.json();
}
+401
View File
@@ -0,0 +1,401 @@
function renderAll() {
renderSummary();
renderHouseClimate();
renderGroups();
renderControlPlan();
renderSimulationPage();
renderDevices();
renderZones();
renderSchedules();
renderAutomations();
renderAccessTokens();
fillSelects();
renderSettings();
renderNightSettings();
renderHomeAssistantSettings();
renderSimulationModeBanner();
renderDebugOverlay();
}
function renderSummary() {
const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite);
const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null;
const online = app.devices.filter(d => d.online).length;
const active = app.devices.filter(d => d.power).length;
const demand = app.zones.filter(z => z.enabled && z.demand).length;
const toolbarStatus = $('#toolbarRuntimeStatus');
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', {active, total:app.devices.length});
$('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}<small>°C</small>`;
const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : '';
$('#summaryText').textContent = app.devices.length
? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix})
: tr('dashboard.empty');
$('#metrics').innerHTML = [
[tr('dashboard.metricOnline'), `${online}/${app.devices.length}`],
[tr('dashboard.metricActive'), active],
[tr('dashboard.metricDemand'), demand],
].map(([label,value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
renderSystemInfo();
}
function renderHouseClimate() {
const node = $('#houseClimate'); if (!node || !app.settings) return;
const mode = app.settings.house_mode || 'cool';
const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto');
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
const powerEnabled = app.settings.house_power_enabled !== false;
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
if (powerOn) { powerOn.classList.toggle('active', powerEnabled); powerOn.setAttribute('aria-pressed', String(powerEnabled)); }
if (powerOff) { powerOff.classList.toggle('active', !powerEnabled); powerOff.setAttribute('aria-pressed', String(!powerEnabled)); }
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
<div class="house-mode-row">
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool" aria-pressed="${mode==='cool'}">${esc(tr('mode.cool'))}</button>
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat" aria-pressed="${mode==='heat'}">${esc(tr('mode.heat'))}</button>
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off" aria-pressed="${mode==='off'}">${esc(tr('house.noControl'))}</button>
</div>
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button class="${preset===p?'active':''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset===p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
}
function planEventMarkup(event) {
const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—';
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
return `<li><time>${esc(when)}</time><span>${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}</span></li>`;
}
function automationTriggerLabel(item) {
if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'});
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
return tr(key, {temperature: fmtTemp(item.threshold)});
}
function planZoneEffectiveEnabled(zone) {
if (!zone) return undefined;
return zone.effective_enabled ?? zone.enabled ?? false;
}
function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return;
const plan = app.controlPlan;
if (!plan) {
host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
return;
}
const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []);
const members = allZones.filter(zone => memberIds.has(zone.zone_id));
const state = groupState(group);
const powerEnabled = group.power_enabled !== false;
const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`})))
.sort((a,b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', {zones:members.length, demand}))}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}</li>`}</ul></article>`;
}).join('');
const zoneGroups = new Map();
(app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => {
const names = zoneGroups.get(zoneId) || [];
names.push(group.name);
zoneGroups.set(zoneId, names);
}));
const zones = allZones.map(zone => {
const events = (zone.next_events || []).slice(0, 2);
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
}).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', {count:rules.length}))}</h3></div></div><ul class="plan-events">${rules.slice(0,3).map(rule=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)}${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}</span></li>`).join('')}</ul></article>` : '';
host.innerHTML = house + groupCards + zones + ruleCard;
}
async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
catch (error) { console.warn('Unable to load control plan:', error); }
}
function simulationTime(value, options = {}) {
if (!value) return '—';
const date = new Date(value);
return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', '');
}
function simulationNumeric(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function simulationOutdoorAssist(mode, outdoor, room, target) {
if (outdoor == null || room == null || target == null) return 0;
const roomError = Math.abs(room - target);
const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1);
return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1);
}
function simulationRoundDeviceSetpoint(mode, demand, value) {
const safe = clamp(Number(value) || 0, 16, 30);
if (mode === 'heat' && demand) return Math.ceil(safe);
if (mode === 'heat' && !demand) return Math.floor(safe);
if (mode !== 'heat' && demand) return Math.floor(safe);
return Math.ceil(safe);
}
function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
if (!demand) return 1;
const error = Math.abs(room - target);
const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32;
if (error >= 2 || extremeWeather) return 3;
if (error >= 1) return 2;
return 0;
}
function simulationStatusInfo(status) {
const map = {
disabled: {label: tr('common.disabled'), badge: ''},
off: {label: tr('house.noControl'), badge: ''},
waiting: {label: tr('simulation.waitingForData'), badge: ''},
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
};
return map[status] || {label: status, badge: ''};
}
function buildZoneSimulation(zone, planZone) {
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1);
const half = hysteresis / 2;
let status = 'waiting';
let demand = false;
if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled';
else if (mode === 'off') status = 'off';
else if (current == null || target == null) status = 'waiting';
else {
if (mode === 'heat') {
if (current <= target - half) demand = true;
else if (current >= target + half) demand = false;
else demand = !!zone?.demand;
} else {
if (current >= target + half) demand = true;
else if (current <= target - half) demand = false;
else demand = !!zone?.demand;
}
status = demand ? 'demand' : 'satisfied';
}
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
const nightActive = !!app.controlPlan?.night_mode_active;
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
let fanSpeed = simulationNumeric(device?.fan_speed);
if (mode !== 'off') {
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
if (nightActive) {
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
}
}
const quiet = mode === 'off'
? tr('simulation.manual')
: nightActive && app.settings?.night_mode?.force_quiet
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false
? tr('simulation.reasonDisabled')
: mode === 'off'
? tr('simulation.reasonHouseOff')
: current == null || target == null
? tr('simulation.reasonWaiting')
: demand
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning};
}
function simulationRuleAction(rule) {
const bits = [];
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`);
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`);
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
}
function simulationGroupsForZone(zoneId) {
return app.groups.filter(group => (group.zone_ids || []).includes(zoneId));
}
function renderSimulationScopeControls(plan) {
const scope = $('#simulationScope');
const target = $('#simulationTarget');
if (!scope || !target) return;
if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units';
scope.value = app.simulationScope;
const zones = plan?.zones || [];
const options = app.simulationScope === 'groups'
? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))]
: [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))];
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
target.innerHTML = options.map(option => `<option value="${esc(option.id)}">${esc(option.name)}</option>`).join('');
target.value = app.simulationTarget;
}
function simulationFilteredZones(plan) {
const zones = plan?.zones || [];
if (app.simulationScope === 'groups') {
if (app.simulationTarget === 'all') {
const grouped = new Set(app.groups.flatMap(group => group.zone_ids || []));
return zones.filter(zone => grouped.has(zone.zone_id));
}
const group = app.groups.find(item => item.id === app.simulationTarget);
const wanted = new Set(group?.zone_ids || []);
return zones.filter(zone => wanted.has(zone.zone_id));
}
return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget);
}
function simulationRuleVisible(rule, zones) {
if (app.simulationTarget === 'all') return true;
const zoneIds = new Set(zones.map(zone => zone.zone_id));
const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean));
if (app.simulationScope === 'groups') {
if (rule.action_group_id === app.simulationTarget) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
const directZone = [...zoneIds][0];
if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
function simulationLaneContext(planZone) {
if (app.simulationScope !== 'groups') return planZone.device_name || '';
const groups = simulationGroupsForZone(planZone.zone_id);
return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || '');
}
function updateSimulationUrl() {
if (app.currentView !== 'simulation') return;
const params = new URLSearchParams();
if (app.standaloneSimulation) params.set('standalone', '1');
if (app.simulationScope !== 'units') params.set('scope', app.simulationScope);
if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget);
const query = params.toString();
updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true);
}
function renderSimulationPage() {
const summaryHost = $('#simulationSummary');
const timelineHost = $('#simulationTimeline');
const boardHost = $('#simulationFlowBoard');
const rulesHost = $('#simulationRules');
if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return;
const plan = app.controlPlan;
if (!plan) {
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = '';
return;
}
renderSimulationScopeControls(plan);
const zones = simulationFilteredZones(plan);
const enabledZones = zones.filter(zone => zone.enabled);
const demandingZones = enabledZones.filter(zone => zone.demand);
const nightOn = !!plan.night_mode_active;
summaryHost.innerHTML = [
{label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')})},
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`},
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085};
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
const nodes = [];
const links = [];
const pathBetween = (ax, ay, bx, by, cls='') => {
const bend = Math.max(36, (bx - ax) * .45);
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax+bend} ${ay}, ${bx-bend} ${by}, ${bx} ${by}"/>`;
};
const verticalLink = (ax, ay, bx, by, cls='bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay+35}, ${bx} ${by-35}, ${bx} ${by}"/>`;
const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`;
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')}),badge:tr('simulation.house')}));
nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''}));
zones.forEach((planZone, index) => {
const zone = app.zones.find(item => item.id === planZone.zone_id);
const sim = buildZoneSimulation(zone, planZone);
const status = simulationStatusInfo(sim.status);
const y = topY + index * rowH;
const mid = y + nodeH / 2;
const next = (planZone.next_events || [])[0];
const source = zoneControlSourceLabel(planZone.control_source);
const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined'
? haSensorLabel(zone?.ha_entity_id || source)
: (planZone.device_name || tr('common.device'));
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
const quietText = sim.quiet;
const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`;
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</span></div>`);
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)}));
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?tr('simulation.running'):tr('simulation.idle')),badgeClass:sim.demand?'active':''}));
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:''}));
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link'));
links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link'));
links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link'));
links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus'));
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus'));
});
boardHost.style.width = `${boardW}px`;
boardHost.style.height = `${boardH}px`;
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
const timelineItems = [];
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
const unique = [], seen = new Set();
for (const item of timelineItems) {
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break;
}
timelineHost.innerHTML = unique.length ? unique.map(item => {
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
}
function scheduleControlPlanLoad() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
}
+332
View File
@@ -0,0 +1,332 @@
function deviceFeaturePanel(device) {
const features = [
['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);
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>`;
}
function deviceZoneDisabled(deviceId) {
return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false);
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
const networkInfo = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
const zoneLocked = !detailed && deviceZoneDisabled(device.id);
const locked = zoneLocked ? ' disabled' : '';
const quickClass = detailed ? '' : ' quick-control-card quick-device-control';
const quickRow = detailed ? '' : ' quick-control-row';
return `<article class="device-card${quickClass} ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"${locked}>⏻</button>
</div>
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}></button>
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</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>
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles${quickRow}${detailed ? '' : ' quick-control-row-3'}">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"${locked}>${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)}"${locked}>${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)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
</div>
${detailed ? deviceFeaturePanel(device) : ''}
${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderDevices() {
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty;
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty;
const dashboardCount = $('#dashboardDeviceCount');
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
}
function zoneStrategyLabel(zone) {
if (zone.sensor_source === 'combined') return tr('zones.combinedSource');
if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource');
return tr('zones.greeSource');
}
function zoneControlSourceLabel(source) {
return ({
device: tr('zones.sourceDevice'),
external: tr('zones.sourceExternal'),
combined: tr('zones.sourceCombined'),
device_fallback: tr('zones.sourceFallback'),
device_discrepancy_fallback: tr('zones.sourceDiscrepancy'),
unavailable: tr('zones.sourceUnavailable'),
})[source] || source || tr('zones.sourceUnavailable');
}
function zonePresetLabel(preset) {
const key = `preset.${preset || 'comfort'}`;
return tr(key) === key ? (preset || 'comfort') : tr(key);
}
function zoneControlOwnerLabel(zone) {
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
const source=zone.control_source || '';
if(owner==='global_off') return tr('zones.ownerGlobalOff');
if(owner==='direct_manual') {
if(source==='home_assistant_direct') return tr('zones.ownerHaDirect');
if(source==='web_direct') return tr('zones.ownerWebDirect');
return tr('zones.ownerExternal');
}
if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat');
return tr('zones.ownerAutomation');
}
function zoneControlOwnerMeta(zone) {
const parts=[];
if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
return parts.join(' · ');
}
function zoneRuntimeStatusLabel(zone, effectiveMode) {
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
if (!zone.enabled) return tr('common.disabled');
if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff');
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
if (zone.current_temperature == null) return tr('zones.noMeasurement');
return zone.demand ? tr('zones.requesting') : tr('zones.satisfied');
}
function zoneCard(zone, detailed = true) {
const device = app.devices.find(d => d.id === zone.device_id);
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
const sensorDetails = zone.sensor_source === 'device'
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
const target = Number(zone.effective_setpoint ?? zone.setpoint);
const manual = zone.manual_preset || 'auto';
const displayPreset = zone.manual_preset || zone.active_preset || 'comfort';
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode);
const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`;
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
const deviceManualOverride = zone.device_manual_override === true;
const localThermostatPower = zone.local_thermostat_power;
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
const temporarySession = zone.temporary_quick_thermostat || null;
const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null;
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
const override = deviceManualOverride
? (zone.device_manual_override_until
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
: tr('zones.manualDeviceNoBoundary'))
: temporarySession
? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'})
: localThermostatOverride
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
: (zone.manual_override_until
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
const manualTakeover = deviceManualOverride
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
: localThermostatOverride && !temporarySession
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}">${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}</p>`
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
: '';
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
const localThermostatPowerControl = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">⏻</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
const temporaryThermostatControl = temporarySession
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><span aria-hidden="true">⏱</span><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
if (detailed) {
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup');
const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly');
return `<article class="list-card zone-config-card ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-config="${esc(zone.id)}">
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
<div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
</div>
<div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
<div><small>${esc(tr('zones.source'))}</small><strong>${esc(zoneStrategyLabel(zone))}</strong></div>
<div><small>${esc(tr('zones.hysteresis'))}</small><strong>${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C</strong></div>
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
</div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
${manualTakeover}
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`;
}
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><div class="zone-quick-actions">${localThermostatPowerControl}${temporaryThermostatControl}</div></div>
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row quick-control-row quick-control-row-4">
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
</div>
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(override)}</span></div>
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
${manualTakeover}
</article>`;
}
function renderZones() {
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
const dashboard = $('#dashboardZones');
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
const dashboardCount = $('#dashboardZoneCount');
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
}
function groupZones(group) {
const wanted = new Set(group?.zone_ids || []);
return app.zones.filter(zone => wanted.has(zone.id));
}
function groupState(group) {
const zones = groupZones(group);
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
return {
zones,
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
};
}
function groupCard(group, detailed = false) {
const state = groupState(group);
const memberNames = state.zones.map(zone => zone.name);
const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers');
const powerEnabled = group.power_enabled !== false;
const masterOff = app.settings?.house_power_enabled === false;
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value);
return `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div></div>
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}</div></div>
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderGroups() {
const empty = `<div class="empty"><strong>${esc(tr('groups.emptyTitle'))}</strong>${esc(tr('groups.emptyText'))}</div>`;
const dashboard = $('#dashboardGroups');
if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty;
const dashboardCount = $('#dashboardGroupCount');
if (dashboardCount) dashboardCount.textContent = String(app.groups.length);
const list = $('#groupList');
if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty;
}
async function sendGroupControl(id, patch) {
try {
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
if (result.group) {
const index = app.groups.findIndex(group => group.id === result.group.id);
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
}
(result.zones || []).forEach(zone => {
const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
});
(result.devices || []).forEach(updateDevice);
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('groups.partial', {count: failed}), true);
else toast(tr('groups.controlUpdated'));
} catch (error) { toast(error.message, true); }
}
function renderGroupZoneChoices(selectedIds = null) {
const host = $('#groupZoneChoices'); if (!host) return;
const selected = new Set(selectedIds || []);
host.innerHTML = app.zones.length ? app.zones.map(zone => {
const device = app.devices.find(item => item.id === zone.device_id);
return `<label class="group-zone-choice"><input type="checkbox" name="zone_ids" value="${esc(zone.id)}" ${selected.has(zone.id) ? 'checked' : ''}><span><strong>${esc(zone.name)}</strong><small>${esc(device?.name || tr('common.noDevice'))}</small></span></label>`;
}).join('') : `<div class="empty compact">${esc(tr('groups.noZones'))}</div>`;
}
function populateGroup(id) {
const item = app.groups.find(group => group.id === id); if (!item) return;
const form = $('#groupForm'); form.reset();
form.elements.id.value = item.id;
form.elements.name.value = item.name;
renderGroupZoneChoices(item.zone_ids || []);
openDialog('groupDialog');
}
function renderSchedules() {
const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`));
$('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => {
const zone = app.zones.find(z => z.id === item.zone_id);
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
function renderAutomations() {
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
function fillSelects() {
const deviceOptions = app.devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
const zoneOptions = app.zones.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
const groupOptions = app.groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)}</option>`).join('');
['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => {
const select = $(selector); if (!select) return;
const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current;
});
const automationGroupSelect = $('#automationForm [name=action_group_id]');
if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; }
const zoneSelect = $('#scheduleForm [name=zone_id]');
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 copyZone=$('#copyZoneSource');
if(copyZone){
const current=copyZone.value;
const editingZoneId=$('#zoneForm')?.dataset.editingZoneId || '';
const sources=app.zones.filter(zone=>zone.id!==editingZoneId);
copyZone.innerHTML=`<option value="">${esc(tr('zones.copyFrom'))}</option>`+sources.map(z=>`<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
copyZone.value=[...copyZone.options].some(option=>option.value===current)?current:'';
}
}
+381
View File
@@ -0,0 +1,381 @@
document.addEventListener('click', async event => {
const button = event.target.closest('button'); if (!button) return;
if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; }
if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; }
if (button.dataset.dashboardTab) { setDashboardTab(button.dataset.dashboardTab); return; }
if (button.dataset.nav) {
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
return;
}
if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; }
if (button.dataset.open) {
const form = document.getElementById(button.dataset.open.replace('Dialog','Form'));
if (form) form.reset();
if (button.dataset.open === 'zoneDialog') {
if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; }
updateZoneSensorFields();
}
if (button.dataset.open === 'groupDialog') {
if (form?.elements.id) form.elements.id.value = '';
renderGroupZoneChoices([]);
}
if (button.dataset.open === 'automationDialog') updateAutomationTargetFields();
if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField();
openDialog(button.dataset.open);
return;
}
if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; }
if (button.dataset.chartLegend !== undefined) {
const id=button.dataset.chartId;
const runtime=chartRuntime.get(id);
const index=Number(button.dataset.chartLegend);
const item=runtime?.series?.[index];
if(item){
setChartSeriesHidden(id,item,index,!isChartSeriesHidden(id,item,index));
redrawHistoryChart(id);
}
return;
}
if (button.dataset.chartZoom) {
const id = button.dataset.chartId;
const current = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
const next = button.dataset.chartZoom === 'in' ? Math.min(MAX_CHART_ZOOM, current * 1.5) : button.dataset.chartZoom === 'out' ? Math.max(1, current / 1.5) : 1;
setChartZoom(id,next);
return;
}
if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; }
if (button.dataset.historyAction) { await handleHistoryAction(button); return; }
if (button.dataset.scheduleTemplate) {
const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true);
if (!confirm(tr('schedules.replaceConfirm'))) return;
try {
button.disabled = true;
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}});
await loadBootstrap(); toast(tr('schedules.templateApplied'));
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
return;
}
const action = button.dataset.action; if (!action) return;
const device = app.devices.find(v => v.id === button.dataset.device);
if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) {
return toast(tr('devices.disabledZoneTechnicalOnly'), true);
}
if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)});
if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true});
if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)});
if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]});
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 === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
if (action === 'house-mode') {
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); }
catch(error){ toast(error.message,true); } return;
}
if (action === 'house-power') {
const power = button.dataset.value === 'true';
if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return;
try {
button.disabled = true;
const result = await api('/api/house/power',{method:'POST',body:{power}});
app.settings = result.settings || app.settings;
app.devices = result.devices || app.devices;
app.groups = result.groups || app.groups;
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('house.powerPartial', {count: failed}), true);
else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone'));
} catch(error) { toast(error.message, true); }
finally { button.disabled = false; }
return;
}
if (action === 'house-preset') {
try {
const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}});
app.settings=result.settings||app.settings; app.zones=result.zones||app.zones; app.devices=result.devices||app.devices;
renderAll(); scheduleControlPlanLoad();
const failed=Array.isArray(result.failed)?result.failed.length:0;
if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated'));
}
catch(error){ toast(error.message,true); } return;
}
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'});
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
if (action === 'zone-resume-automation') {
const patch = button.dataset.resume === 'local'
? {clear_local_thermostat_override:true}
: {clear_device_manual_override:true};
return sendZoneControl(button.dataset.id, patch);
}
if (action === 'zone-go-control') {
showView('dashboard', {scroll:false});
setDashboardTab('thermostats', {scroll:false});
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
requestAnimationFrame(() => target?.scrollIntoView({behavior:'smooth', block:'center'}));
return;
}
if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
if (action === 'edit-group') return populateGroup(button.dataset.id);
if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group');
if (action === 'edit-zone') return populateZone(button.dataset.id);
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule');
if (action === 'edit-automation') return populateAutomation(button.dataset.id);
if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation');
if (action === 'revoke-access-token') {
if (!confirm(tr('confirm.revokeToken'))) return;
try {
await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'});
app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id);
renderAccessTokens();
toast(tr('toast.tokenRevoked'));
} catch (error) { toast(error.message, true); }
return;
}
});
$('#refreshButton').addEventListener('click', () => {
const form = activeDirtySettingsForm();
if (form && !confirmDiscardForm(form)) return;
loadBootstrap(true);
});
$('#connectionRetry')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
if (app.ws) { try { app.ws.close(); } catch (_) {} app.ws = null; }
await loadBootstrap(false);
connectWebSocket();
} finally { button.disabled = false; }
});
$('#discoverButton').addEventListener('click', () => {
const form=$('#discoverForm'); form.reset();
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');
});
$('#historyRefresh').addEventListener('click', loadHistory);
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
$('#logsRefresh').addEventListener('click', loadLogs);
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value));
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
$('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); });
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
$('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } });
document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); });
$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60),alert_types:{stale_sensor:f.notification_alert_stale_sensor.checked,sensor_errors:f.notification_alert_sensor_errors.checked,communication:f.notification_alert_communication.checked,target_timeout:f.notification_alert_target_timeout.checked,automation:f.notification_alert_automation.checked,control_errors:f.notification_alert_control_errors.checked,important_events:f.notification_alert_important_events.checked,other:f.notification_alert_other.checked}}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast(tr('notifications.testSent'));}catch(e){toast(e.message,true);} });
$('#copyZoneSettings')?.addEventListener('click',()=>{
const f=$('#zoneForm');
const source=app.zones.find(zone=>zone.id===$('#copyZoneSource')?.value);
if(!source)return;
const targetId=f.dataset.editingZoneId || '';
if(targetId && source.id===targetId){ toast(tr('zones.copyDifferent'),true); return; }
// Identity and sensor assignment belong to the destination profile and must never
// be changed by copying thermostat tuning from another profile.
const protectedValues={
id:f.elements.id.value,
name:f.elements.name.value,
device_id:f.elements.device_id.value,
sensor_source:f.elements.sensor_source.value,
ha_entity_id:f.elements.ha_entity_id.value,
enabled:f.elements.enabled.checked,
};
const copyFields=['setpoint','cool_comfort_setpoint','cool_sleep_setpoint','cool_away_setpoint','heat_comfort_setpoint','heat_sleep_setpoint','heat_away_setpoint','hysteresis','min_on_seconds','min_off_seconds','min_adjust_seconds','standby_offset_c','external_sensor_weight_percent','max_sensor_difference','sensor_stale_after_seconds'];
copyFields.forEach(name=>{
const input=f.elements[name];
if(!input)return;
const value=name==='external_sensor_weight_percent'?(source.external_sensor_weight*100):source[name];
if(value!==undefined&&value!==null)input.value=String(value);
});
f.elements.smart_fan.checked=source.smart_fan!==false;
if(f.elements.mode_policy) f.elements.mode_policy.value=source.inherit_house_mode===false?(source.mode||'cool'):'house';
f.elements.id.value=protectedValues.id;
f.elements.name.value=protectedValues.name;
f.elements.device_id.value=protectedValues.device_id;
f.elements.sensor_source.value=protectedValues.sensor_source;
f.elements.ha_entity_id.value=protectedValues.ha_entity_id;
f.elements.enabled.checked=protectedValues.enabled;
f.dataset.editingZoneId=targetId;
updateZoneSensorFields();
updateDirtyIndicator(f);
toast(tr('zones.copiedFrom',{name:source.name}));
});
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:tr('automations.presetHot'),trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:tr('automations.presetCold'),trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:tr('automations.presetMorning'),trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:tr('automations.presetNightOff'),trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; });
$('#tokenForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
if (!validateForm(form) || form.dataset.submitting === 'true') return;
setFormBusy(form, true, 'auth.connecting');
app.token = new FormData(form).get('token').trim();
localStorage.setItem('gree_controller_token', app.token);
if (app.ws) app.ws.close();
try { await loadBootstrap(); }
finally { setFormBusy(form, false); }
});
$('#discoverForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
await runFormTask(form, async () => {
const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}});
form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []);
}, {busyKey:'actions.discovering'});
});
$('#discoveryNamesForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
const inputs = $$('input[data-device-id]', form);
await runFormTask(form, async () => {
const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}})));
updated.forEach(updateDevice);
form.closest('dialog').close();
renderAll();
toast(tr('common.saved'));
});
});
$('#renameDeviceForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
await runFormTask(form, async () => {
const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}});
updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved'));
});
});
$('#deviceForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form));
data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked;
await runFormTask(form, async () => {
await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added'));
});
});
$('#zoneForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=form.dataset.editingZoneId || raw.id; const body={
name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,
mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint),
cool_comfort_setpoint:parseDecimal(raw.cool_comfort_setpoint),cool_sleep_setpoint:parseDecimal(raw.cool_sleep_setpoint),cool_away_setpoint:parseDecimal(raw.cool_away_setpoint),
heat_comfort_setpoint:parseDecimal(raw.heat_comfort_setpoint),heat_sleep_setpoint:parseDecimal(raw.heat_sleep_setpoint),heat_away_setpoint:parseDecimal(raw.heat_away_setpoint),
hysteresis:parseDecimal(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),
min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked,
sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference),sensor_stale_after_seconds:Number(raw.sensor_stale_after_seconds||300),revision:raw.revision?Number(raw.revision):null
};
await runFormTask(form, async () => {
await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
});
});
$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields);
$('#temporaryStartKind')?.addEventListener('change', updateTemporaryThermostatFields);
$$('[data-temporary-start-delay]').forEach(button => button.addEventListener('click', () => {
const form = $('#temporaryThermostatForm');
form.start_delay_minutes.value = button.dataset.temporaryStartDelay;
}));
$$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => {
const form = $('#temporaryThermostatForm');
form.duration_minutes.value = button.dataset.temporaryDuration;
}));
$('#temporaryThermostatStop')?.addEventListener('click', async event => {
const id = event.currentTarget.dataset.id; if (!id) return;
try {
event.currentTarget.disabled = true;
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{clear_temporary_quick_thermostat:true}});
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
} catch (error) { toast(error.message, true); }
finally { event.currentTarget.disabled = false; }
});
$('#temporaryThermostatForm')?.addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
const activeSession = form.dataset.activeSession === 'true';
const zone = app.zones.find(item => item.id === raw.zone_id);
const existingSession = zone?.temporary_quick_thermostat;
const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind;
const request = {start_kind:startKind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)};
if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes);
if (!activeSession && startKind === 'at') {
const startAt = new Date(raw.start_at);
if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true);
request.start_at = startAt.toISOString();
}
if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes);
if (raw.finish_kind === 'until') {
const until = new Date(raw.until);
if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true);
const effectiveStart = activeSession ? new Date()
: startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000)
: startKind === 'at' ? new Date(raw.start_at) : new Date();
if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true);
request.until = until.toISOString();
}
if (['temperature_reached','temperature_stable'].includes(raw.finish_kind)) {
request.temperature_operator = raw.temperature_operator;
request.tolerance_c = parseDecimal(raw.tolerance_c);
if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes);
const safety = Number(raw.max_duration_minutes);
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
}
await runFormTask(form, async () => {
const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}});
const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone;
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
}, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'});
});
$('#groupForm')?.addEventListener('submit', async event => {
event.preventDefault();
const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id || '';
const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value);
if (!zone_ids.length) return toast(tr('groups.chooseMember'), true);
const existing=id ? app.groups.find(group => group.id===id) : null;
const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false};
await runFormTask(form, async () => {
await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body});
form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
});
});
$('#scheduleForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:parseDecimal(raw.setpoint||23)};
await runFormTask(form, async () => {
await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
});
});
$('#automationForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
const groupTarget=raw.action_target_kind==='group';
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature);
const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)};
await runFormTask(form, async () => {
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'));
});
});
+302
View File
@@ -0,0 +1,302 @@
function toast(message, error = false) {
const host = $('#toastStack'); if (!host) return;
const item = document.createElement('div');
item.className = `toast-item ${error ? 'error' : 'success'}`;
item.innerHTML = `<span class="toast-icon">${error ? '!' : '✓'}</span><div class="toast-copy"><strong>${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}</strong><span>${esc(message)}</span></div><button class="toast-close" type="button" aria-label="${esc(tr('actions.close'))}">×</button><i class="toast-progress"></i>`;
host.appendChild(item);
requestAnimationFrame(() => item.classList.add('show'));
const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); };
item.querySelector('.toast-close').addEventListener('click', remove);
item._timer = setTimeout(remove, error ? 5200 : 3600);
}
const cleanFormSnapshots = new WeakMap();
function formSnapshot(form) {
if (!form) return '';
const rows = [];
$$('input, select, textarea', form).forEach((field, index) => {
if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return;
const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`;
const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? '');
rows.push([key, value, String(field.disabled)]);
});
return JSON.stringify(rows);
}
function markFormClean(form) {
if (!form) return;
cleanFormSnapshots.set(form, formSnapshot(form));
form.classList.remove('has-unsaved-changes');
}
function updateDirtyIndicator(form) {
if (!form || !cleanFormSnapshots.has(form)) return;
form.classList.toggle('has-unsaved-changes', isFormDirty(form));
}
function isFormDirty(form) {
if (!form || !cleanFormSnapshots.has(form)) return false;
return cleanFormSnapshots.get(form) !== formSnapshot(form);
}
function clearFormErrors(form) {
if (!form) return;
$$('.field-error, .form-error-summary', form).forEach(node => node.remove());
$$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid'));
$$('.has-error', form).forEach(node => node.classList.remove('has-error'));
}
function showFieldError(field, message) {
if (!field || !message) return;
field.setAttribute('aria-invalid', 'true');
const label = field.closest('label');
if (label) label.classList.add('has-error');
const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null;
if (existing) { existing.textContent = message; return; }
const note = document.createElement('small');
note.className = 'field-error';
note.textContent = message;
field.insertAdjacentElement('afterend', note);
}
function showFormError(form, message) {
if (!form || !message) return;
const summary = document.createElement('div');
summary.className = 'form-error-summary';
summary.setAttribute('role', 'alert');
summary.textContent = message;
const anchor = $('.settings-save-bar', form) || $('.form-actions', form);
if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor);
else form.appendChild(summary);
}
function validationMessageFor(field) {
if (field.validity?.valueMissing) return tr('validation.required');
if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid');
if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) {
return tr('validation.range', {min:field.min || '—', max:field.max || '—'});
}
if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid');
return field.validity?.valid === false ? tr('validation.invalid') : '';
}
function httpUrlValid(value) {
if (!String(value || '').trim()) return true;
try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; }
}
function entityIdValid(value) {
return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim());
}
function validateDecimalField(form, name, min, max) {
const field = form?.elements?.[name];
if (!field || field.disabled) return true;
const value = parseDecimal(field.value);
if (!Number.isFinite(value) || value < min || value > max) {
showFieldError(field, tr('validation.range', {min, max}));
return false;
}
return true;
}
function validateForm(form) {
if (!form) return true;
clearFormErrors(form);
let valid = true;
$$('input, select, textarea', form).forEach(field => {
if (field.disabled || field.type === 'hidden' || field.type === 'file') return;
const message = validationMessageFor(field);
if (message) { showFieldError(field, message); valid = false; }
});
if (form.id === 'homeAssistantForm') {
const url = form.elements.ha_url;
if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
['ha_entity_id','ha_outdoor_entity_id'].forEach(name => {
const field = form.elements[name];
if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; }
});
}
if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) {
const url = form.elements.influx_url;
if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; }
else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
}
if (form.id === 'zoneForm') {
const source = form.elements.sensor_source?.value;
const entity = form.elements.ha_entity_id;
if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) {
showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required'));
valid = false;
}
[
['cool_comfort_setpoint',8,30],['cool_sleep_setpoint',8,30],['cool_away_setpoint',8,30],
['heat_comfort_setpoint',8,30],['heat_sleep_setpoint',8,30],['heat_away_setpoint',8,30],
['hysteresis',0.1,5],['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8]
].forEach(([name,min,max]) => { if (!validateDecimalField(form,name,min,max)) valid = false; });
}
if (!valid) {
showFormError(form, tr('validation.fixFields'));
const first = $('[aria-invalid="true"]', form);
const pane = first?.closest?.('[data-settings-pane]');
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
first?.focus({preventScroll:true});
first?.scrollIntoView({behavior:'smooth', block:'center'});
}
return valid;
}
function apiErrorField(form, message) {
const text = String(message || '').toLowerCase();
const candidates = [];
if (text.includes('influx')) candidates.push('influx_url');
if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url');
if (text.includes('entity')) candidates.push('ha_entity_id','ha_outdoor_entity_id');
if (text.includes('device')) candidates.push('device_id','action_device_id','trigger_device_id');
if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature','cool_comfort_setpoint','action_target_temperature');
if (text.includes('name')) candidates.push('name','controller_id');
if (text.includes('token')) candidates.push('ha_token','influx_token','pushover_app_token');
if (text.includes('url')) candidates.push('ha_url','influx_url','slack_webhook_url','discord_webhook_url');
return candidates.map(name => form?.elements?.[name]).find(Boolean) || null;
}
function presentFormError(form, error) {
clearFormErrors(form);
const message = error?.message || tr('validation.invalid');
const field = apiErrorField(form, message);
if (field) showFieldError(field, message);
showFormError(form, message);
if (field) {
const pane = field.closest?.('[data-settings-pane]');
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
field.focus({preventScroll:true});
field.scrollIntoView({behavior:'smooth', block:'center'});
}
toast(message, true);
}
function setFormBusy(form, busy, busyKey = 'actions.saving') {
if (!form) return;
const buttons = $$('button[type="submit"]', form);
if (busy) {
form.dataset.submitting = 'true';
form.setAttribute('aria-busy', 'true');
form.classList.add('is-saving');
buttons.forEach(button => {
if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = `<span class="button-spinner" aria-hidden="true"></span><span>${esc(tr(busyKey))}</span>`;
});
} else {
delete form.dataset.submitting;
form.removeAttribute('aria-busy');
form.classList.remove('is-saving');
buttons.forEach(button => {
button.disabled = false;
if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; }
});
}
}
async function runFormTask(form, task, {busyKey='actions.saving', validate=true}={}) {
if (!form || form.dataset.submitting === 'true') return {ok:false, duplicate:true};
if (validate && !validateForm(form)) return {ok:false, validation:true};
clearFormErrors(form);
setFormBusy(form, true, busyKey);
try {
const value = await task();
markFormClean(form);
return {ok:true, value};
} catch (error) {
presentFormError(form, error);
return {ok:false, error};
} finally {
setFormBusy(form, false);
}
}
function restoreTrackedForm(form) {
if (!form) return;
if (form.id === 'settingsForm') renderSettings();
else if (form.id === 'nightModeForm') renderNightSettings();
else if (form.id === 'homeAssistantForm') {
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
renderHomeAssistantSettings();
} else markFormClean(form);
}
function confirmDiscardForm(form) {
if (!isFormDirty(form)) return true;
if (!confirm(tr('confirm.discardChanges'))) return false;
restoreTrackedForm(form);
return true;
}
function activeDirtySettingsForm() {
const active = $('.view.active');
if (!active) return null;
const form = $('form.settings-form', active);
return form && isFormDirty(form) ? form : null;
}
function canLeaveCurrentView(nextName) {
if (nextName === app.currentView) return true;
const form = activeDirtySettingsForm();
return !form || confirmDiscardForm(form);
}
function requestDialogClose(dialog) {
if (!dialog) return true;
const form = $('form', dialog);
if (form && !confirmDiscardForm(form)) return false;
dialog.close();
return true;
}
function setupFormUx() {
$$('form').forEach(form => {
form.noValidate = true;
if (!cleanFormSnapshots.has(form)) markFormClean(form);
});
}
document.addEventListener('input', event => {
const field = event.target.closest?.('input, select, textarea');
const form = field?.form;
if (!field || !form) return;
if (field.getAttribute('aria-invalid') === 'true') {
field.removeAttribute('aria-invalid');
field.closest('label')?.classList.remove('has-error');
if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove();
$('.form-error-summary', form)?.remove();
}
updateDirtyIndicator(form);
});
document.addEventListener('change', event => updateDirtyIndicator(event.target?.form));
window.addEventListener('beforeunload', event => {
const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form));
if (!dirty) return;
event.preventDefault();
event.returnValue = '';
});
document.addEventListener('cancel', event => {
const dialog = event.target.closest?.('dialog');
if (!dialog) return;
const form = $('form', dialog);
if (!form || !isFormDirty(form)) return;
event.preventDefault();
if (confirmDiscardForm(form)) dialog.close();
}, true);
function showTokenDialog() {
const dialog = $('#tokenDialog');
if (!dialog.open) dialog.showModal();
}
+198
View File
@@ -0,0 +1,198 @@
function renderHistorySummary() {
const host=$('#historySummary'); if(!host) 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])=>`<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).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=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
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=devices.filter(device=>rows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
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 customSeriesOptions() {
const items=[];
app.devices.forEach(device=>{
items.push([`device|${device.id}|indoor`,`${device.name} · ${tr('history.indoorTemperature')}`]);
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==='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=`<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value,label])=>`<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
<div class="custom-series-list">${selected.length?selected.map((item,index)=>`<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="${esc(tr('actions.remove'))}">×</button></span>`).join(''):`<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
<div class="custom-chart-save"><input id="customChartName" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(tr('actions.save'))}</button><button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
<div class="saved-chart-list">${app.savedCharts.length?app.savedCharts.map(item=>`<div class="saved-chart-row"><button data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))}</small></button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}">×</button></div>`).join(''):`<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div>
</div>`;
}
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==='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;
}
}
+20
View File
@@ -0,0 +1,20 @@
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(withBase('/sw.js')).catch(()=>{}));
async function startApplication() {
observeTopbarHeight();
applyTheme();
await loadLanguages();
applyTranslations();
setupFormUx();
updateZoneSensorFields();
updateSchedulePresetField();
await loadBootstrap();
initRouter();
}
setInterval(updateLocalThermostatCountdowns, 1000);
setInterval(updateTemporaryThermostatCountdowns, 1000);
startApplication().catch(error => console.error('Application startup failed:', error));
+288
View File
@@ -0,0 +1,288 @@
function setDashboardTab(tab, {scroll=true}={}) {
const next = ['main','thermostats','manual'].includes(tab) ? tab : 'main';
app.dashboardTab = next;
$$('[data-dashboard-tab]').forEach(button => {
const active = button.dataset.dashboardTab === next;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
$$('[data-dashboard-panel]').forEach(panel => {
const active = panel.dataset.dashboardPanel === next;
panel.classList.toggle('active', active);
panel.hidden = !active;
});
if (scroll && app.currentView === 'dashboard') window.scrollTo({top:0, behavior:'smooth'});
}
function showView(name, {push=true, scroll=true}={}) {
if (!canLeaveCurrentView(name)) return false;
app.currentView = 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' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name))));
if (push) updateBrowserUrl(pathForView(name));
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
if (name === 'dashboard') setDashboardTab(app.dashboardTab, {scroll:false});
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
if (name === 'logs') loadLogs();
return true;
}
function showHistoryTab(tab, {push=true, load=true}={}) {
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
renderHistoryNavigation();
if (push) updateBrowserUrl(currentHistoryPath());
if (load) loadHistory();
}
async function sendDeviceCommand(id, command) {
try {
const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command});
updateDevice(device); renderAll();
} catch (error) { toast(error.message, true); }
}
function updateDevice(device) {
const index = app.devices.findIndex(item => item.id === device.id);
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
}
async function sendZoneLocalPower(id, power) {
try {
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}});
const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
} catch (error) { toast(error.message, true); }
}
async function sendZoneControl(id, patch) {
const sequence = (app.zoneControlSeq[id] || 0) + 1;
app.zoneControlSeq[id] = sequence;
try {
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
if (app.zoneControlSeq[id] !== sequence) return;
const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
renderSummary(); renderZones(); scheduleControlPlanLoad();
} catch (error) {
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
}
}
function queueZoneTemperature(zone, value) {
const next = Math.round(clamp(value, 8, 30) * 2) / 2;
zone.manual_setpoint = next;
zone.setpoint = next;
zone.effective_setpoint = next;
renderZones();
clearTimeout(app.zoneTemperatureTimers[zone.id]);
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160);
}
function showDiscoveryNames(ids) {
const wanted = new Set(Array.isArray(ids) ? ids : []);
const devices = app.devices.filter(device => wanted.has(device.id));
if (!devices.length) return;
const list = $('#discoveryNamesList');
list.innerHTML = devices.map(device => `
<label class="discovery-name-row">
<span><strong>${esc(device.model || 'GREE')}</strong><small>${esc(device.ip)} · ${esc(device.mac)} · ${device.protocol_version === 2 ? 'V2 GCM' : 'V1 ECB'}</small></span>
<input data-device-id="${esc(device.id)}" maxlength="80" required value="${esc(device.name)}">
</label>`).join('');
openDialog('discoveryNamesDialog');
}
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.protocol_version.value = String(device.protocol_version ?? 0);
openDialog('renameDeviceDialog');
}
async function deleteEntity(type, id, labelKey) {
if (!confirm(tr('confirm.delete', {label: tr(labelKey)}))) return;
try { await api(`/api/${type}/${encodeURIComponent(id)}`, {method:'DELETE'}); await loadBootstrap(); toast(tr('common.removed')); }
catch (error) { toast(error.message, true); }
}
function openDialog(id) {
fillSelects();
const dialog = document.getElementById(id);
if (dialog && !dialog.open) {
const form = $('form', dialog);
if (form) { clearFormErrors(form); markFormClean(form); }
dialog.showModal();
}
}
function updateZoneSensorFields() {
const form = $('#zoneForm');
if (!form) return;
const external = form.sensor_source.value !== 'device';
$('#externalSensorFields').hidden = !external;
form.ha_entity_id.required = external;
}
function updateSchedulePresetField() {
const form = $('#scheduleForm'); if (!form) return;
$('#scheduleSetpointField').hidden = form.preset.value !== 'custom';
form.setpoint.required = form.preset.value === 'custom';
}
function updateAutomationTargetFields() {
const form = $('#automationForm'); if (!form) return;
const groupTarget = form.action_target_kind.value === 'group';
$('#automationDeviceTarget').hidden = groupTarget;
$('#automationGroupTarget').hidden = !groupTarget;
$('#automationGroupPreset').hidden = !groupTarget;
$('#automationTargetTemperature').hidden = groupTarget;
$('#automationGroupHint').hidden = !groupTarget;
form.action_device_id.required = !groupTarget;
form.action_group_id.required = groupTarget;
form.action_target_temperature.disabled = groupTarget;
if (groupTarget) form.action_target_temperature.value = '';
[...form.action_mode.options].forEach(option => {
if (!['dry','fan'].includes(option.value)) return;
option.disabled = groupTarget;
option.hidden = groupTarget;
});
if (groupTarget && ['dry','fan'].includes(form.action_mode.value)) form.action_mode.value = '';
}
function populateZone(id) {
const item = app.zones.find(v => v.id === id); if (!item) return;
const form = $('#zoneForm');
form.reset();
form.dataset.editingZoneId = item.id;
form.elements.id.value = item.id;
fillSelects();
Object.entries(item).forEach(([key,value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool');
if (Number(item.profile_version || 0) === 0) {
if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21);
else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23);
}
form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100);
form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3);
form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120);
form.standby_offset_c.value = Number(item.standby_offset_c ?? 2);
form.smart_fan.checked = item.smart_fan !== false;
form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog');
}
const dateTimeLocalValue = value => {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return '';
const pad = number => String(number).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
function updateTemporaryThermostatFields() {
const form = $('#temporaryThermostatForm'); if (!form) return;
const startKind = form.start_kind.value;
const kind = form.finish_kind.value;
const startDelay = $('#temporaryStartDelayFields');
const startAt = $('#temporaryStartAtFields');
const duration = $('#temporaryDurationFields');
const until = $('#temporaryUntilFields');
const temperature = $('#temporaryTemperatureFields');
const schedule = $('#temporaryScheduleFields');
startDelay.hidden = startKind !== 'delay';
startAt.hidden = startKind !== 'at';
duration.hidden = kind !== 'duration';
until.hidden = kind !== 'until';
temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind);
schedule.hidden = kind !== 'schedule_boundary';
$('#temporaryHoldField').hidden = kind !== 'temperature_stable';
form.start_delay_minutes.required = startKind === 'delay';
form.start_at.required = startKind === 'at';
form.duration_minutes.required = kind === 'duration';
form.until.required = kind === 'until';
form.hold_minutes.required = kind === 'temperature_stable';
}
function populateTemporaryThermostat(id) {
const zone = app.zones.find(item => item.id === id); if (!zone) return;
const form = $('#temporaryThermostatForm');
form.reset();
form.elements.zone_id.value = zone.id;
form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1);
form.start_kind.value = 'now';
form.start_delay_minutes.value = '30';
form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000));
form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000));
const session = zone.temporary_quick_thermostat;
const activeSession = !!session?.activated_at;
form.dataset.activeSession = activeSession ? 'true' : 'false';
if (session) {
form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now');
if (form.start_kind.value === 'delay' && session.started_at) {
form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000));
}
if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at);
form.finish_kind.value = session.finish_kind || 'duration';
if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1);
if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator;
form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1);
if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60));
if (session.finish_kind === 'duration') {
if (session.duration_seconds) {
form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60));
} else if (session.expires_at) {
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000));
}
}
if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at);
if (session.safety_duration_seconds) {
form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60));
} else if (session.safety_expires_at) {
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000));
} else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) {
form.max_duration_minutes.value = '';
}
}
// Once ownership has started, the historical start is informational. Editing changes only
// target/finish rules; rescheduling requires stopping the session and creating a new one.
form.start_kind.disabled = activeSession;
form.start_delay_minutes.disabled = activeSession;
form.start_at.disabled = activeSession;
$$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; });
updateTemporaryThermostatFields();
const active = $('#temporaryThermostatActive');
active.hidden = !session;
$('#temporaryThermostatStop').dataset.id = zone.id;
$('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart');
updateTemporaryThermostatCountdowns();
openDialog('temporaryThermostatDialog');
updateTemporaryThermostatCountdowns();
}
function populateSchedule(id) {
const item = app.schedules.find(v => v.id === id); if (!item) return;
const form = $('#scheduleForm'); form.reset(); fillSelects();
['id','name','zone_id','start_time','end_time','setpoint','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
form.enabled.checked = item.enabled;
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
updateSchedulePresetField(); openDialog('scheduleDialog');
}
function populateAutomation(id) {
const item = app.automations.find(v => v.id === id); if (!item) return;
const form = $('#automationForm'); form.reset(); fillSelects();
['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
const groupTarget = !!item.action_group_id;
form.action_target_kind.value = groupTarget ? 'group' : 'device';
if (groupTarget) form.action_group_id.value = item.action_group_id;
form.action_power.value = item.action.power == null ? '' : String(item.action.power);
form.action_mode.value = item.action.mode || '';
form.action_preset.value = item.action_preset || '';
form.action_target_temperature.value = item.action.target_temperature ?? '';
form.enabled.checked = item.enabled;
updateAutomationTargetFields();
openDialog('automationDialog');
}
+62
View File
@@ -0,0 +1,62 @@
async function loadLogs() {
renderLogRetention();
try {
const data = await api('/api/events?limit=150');
const level=$('#logLevelFilter')?.value||'all', category=$('#logCategoryFilter')?.value||'all';
const logs = (data.events || []).filter(item => (level==='all'||item.level===level) && (category==='all'||logCategory(item.kind)===category));
$('#logList').innerHTML = logs.length
? logs.map(item => `<div class="log-row ${esc(item.level)} category-${esc(logCategory(item.kind))}"><time>${esc(new Date(item.timestamp).toLocaleTimeString(locale()))}</time><span class="log-category">${esc(logCategory(item.kind))}</span><span class="kind">${esc(item.kind)}</span><span class="message">${esc(item.message)}</span></div>`).join('')
: `<div class="empty"><strong>${esc(tr('logs.emptyTitle'))}</strong>${esc(tr('logs.emptyText'))}</div>`;
} catch (error) { toast(error.message, true); }
}
function connectWebSocket() {
if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return;
clearTimeout(app.wsTimer);
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws;
ws.onopen = () => updateConnectionIndicator('connected');
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
ws.onerror = () => updateConnectionIndicator('connectionError');
ws.onmessage = event => {
try {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.systemSnapshotAt=Date.now(); app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
}
const data = message.data || {};
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.updated') {
const settingsDirty=isFormDirty($('#settingsForm')), nightDirty=isFormDirty($('#nightModeForm')), haDirty=isFormDirty($('#homeAssistantForm'));
app.settings=data;
if (!haDirty) app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})};
if (!settingsDirty) renderSettings();
if (!nightDirty) renderNightSettings();
if (!haDirty) renderHomeAssistantSettings();
renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad();
}
else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'gree.frame_received') {
app.system = app.system || {};
app.system.gree_received_frames = Number(data.total || 0);
app.system.gree_received_frames_by_device = {...(app.system.gree_received_frames_by_device || {})};
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
renderGreeFrameStats();
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 === '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.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad();
} catch (_) {}
};
}
+97
View File
@@ -0,0 +1,97 @@
const VIEW_ROUTES = Object.freeze({
dashboard: '/dashboard',
devices: '/devices',
zones: '/zones',
groups: '/groups',
schedules: '/schedules',
automations: '/automations',
simulation: '/simulation',
night: '/night-mode',
homeassistant: '/home-assistant',
settings: '/settings',
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']);
let routerInitialized = false;
function currentHistoryPath() {
const tab = HISTORY_TABS.includes(app.historyTab) ? app.historyTab : 'overview';
const params = new URLSearchParams();
const hours = $('#historyHours')?.value || new URLSearchParams(location.search).get('hours') || '24';
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 === '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();
return `/history/${tab}${query ? `?${query}` : ''}`;
}
function pathForView(name) {
return name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || VIEW_ROUTES.dashboard);
}
function appRelativePath(pathname = location.pathname) {
if (!APP_BASE) return pathname || '/';
if (pathname === APP_BASE) return '/';
if (pathname.startsWith(`${APP_BASE}/`)) return pathname.slice(APP_BASE.length) || '/';
return pathname || '/';
}
function updateBrowserUrl(path, replace = false) {
const normalized = path.startsWith('/') ? path : `/${path}`;
const target = `${APP_BASE}${normalized}` || normalized;
const current = `${location.pathname}${location.search}`;
if (current === target) return;
history[replace ? 'replaceState' : 'pushState']({}, '', target);
}
function restoreCurrentRoute(previousView) {
updateBrowserUrl(previousView === 'history' ? currentHistoryPath() : pathForView(previousView), true);
}
function applyRouteFromLocation() {
const routePath = appRelativePath();
const parts = routePath.split('/').filter(Boolean);
const first = parts[0] || 'dashboard';
const params = new URLSearchParams(location.search);
const previousView = app.currentView;
app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1';
document.body.classList.toggle('simulation-standalone', app.standaloneSimulation);
if (first === 'simulation') {
app.simulationScope = ['units', 'groups'].includes(params.get('scope')) ? params.get('scope') : 'units';
app.simulationTarget = params.get('target') || 'all';
}
if (first === 'history') {
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
app.historyZone = params.get('zone') || 'all';
app.historyDevice = params.get('device') || 'all';
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;
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
if (!showView('history', {push: false, scroll: false})) restoreCurrentRoute(previousView);
return;
}
const view = first === 'index.html' ? 'dashboard' : ROUTE_VIEWS[`/${first}`];
if (!showView(view || 'dashboard', {push: false, scroll: false})) {
restoreCurrentRoute(previousView);
return;
}
// Keep a canonical URL for the application root and legacy /index.html entry point.
if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true);
}
function initRouter() {
if (!routerInitialized) {
window.addEventListener('popstate', applyRouteFromLocation);
routerInitialized = true;
}
applyRouteFromLocation();
}
+231
View File
@@ -0,0 +1,231 @@
function renderAccessTokens() {
const list = $('#accessTokenList');
if (!list) return;
list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => `
<div class="token-row">
<div><strong>${esc(item.name)}</strong><small class="mono">${esc(item.token_prefix)}</small><small>${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}</small></div>
<button type="button" class="danger" data-action="revoke-access-token" data-id="${esc(item.id)}">${esc(tr('actions.revoke'))}</button>
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
}
function knownHaEntities() {
return [...new Set([
...Object.keys(app.sensorAliases || {}),
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
app.settings?.home_assistant?.default_entity_id,
app.settings?.home_assistant?.outdoor_entity_id,
...app.historyData.sensors.map(row => row.entity_id),
].filter(Boolean))].sort();
}
function renderSensorAliases() {
const host = $('#sensorAliasList'); if (!host) return;
const entities = knownHaEntities();
host.innerHTML = entities.length ? entities.map(entity => `<div class="sensor-alias-row"><span class="mono" title="${esc(entity)}">${esc(entity)}</span><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">×</button></div>`).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
}
function renderLogRetention() {
const select = $('#logRetentionDays'); if (!select || !app.settings) return;
[...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; });
const days = String(app.settings.event_log_retention_days || 30);
if (![...select.options].some(option => option.value === days)) {
const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option);
}
select.value = days;
}
function renderSettings() {
if (!app.settings) return;
const form = $('#settingsForm');
if (!form) return;
form.controller_id.value = app.settings.controller_id || '';
form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15;
form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5;
form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000';
form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000;
form.simulator_enabled.checked = !!app.settings.simulator_enabled;
form.history_retention_days.value = app.settings.history_retention_days || 30;
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;
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
form.influx_version.value = String(app.settings.influxdb?.version || '2');
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
form.influx_url.value = app.settings.influxdb?.url || '';
form.influx_database.value = app.settings.influxdb?.database || 'gree_controller';
form.influx_username.value = app.settings.influxdb?.username || '';
form.influx_password.value = '';
form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.influx_org.value = app.settings.influxdb?.org || '';
form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller';
form.influx_token.value = '';
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;
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='';
form.pushover_app_token.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.applicationTokenPlaceholder');
form.pushover_user_key.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.userKeyPlaceholder');
form.slack_webhook_url.placeholder=n.slack_configured?tr('settings.secretSaved'):'https://hooks.slack.com/services/…';
form.discord_webhook_url.placeholder=n.discord_configured?tr('settings.secretSaved'):'https://discord.com/api/webhooks/…';
form.notification_cooldown_seconds.value=n.cooldown_seconds||300; form.notification_failure_threshold.value=n.communication_failure_threshold||3; form.notification_target_timeout.value=n.target_timeout_minutes||60;
const alerts=n.alert_types||{};
form.notification_alert_stale_sensor.checked=alerts.stale_sensor!==false;
form.notification_alert_sensor_errors.checked=alerts.sensor_errors!==false;
form.notification_alert_communication.checked=alerts.communication!==false;
form.notification_alert_target_timeout.checked=alerts.target_timeout!==false;
form.notification_alert_automation.checked=alerts.automation!==false;
form.notification_alert_control_errors.checked=alerts.control_errors!==false;
form.notification_alert_important_events.checked=alerts.important_events!==false;
form.notification_alert_other.checked=alerts.other!==false;
updateNotificationFields();
updateInfluxFields();
renderLogRetention();
renderGreeFrameStats();
renderSystemInfo();
renderSimulationModeBanner();
setSettingsTab(app.settingsTab);
markFormClean(form);
}
function renderSimulationModeBanner() {
const banner = $('#simulationModeBanner');
if (!banner) return;
const enabled = !!app.settings?.simulator_enabled;
banner.hidden = !enabled;
document.body.classList.toggle('simulation-mode-enabled', enabled);
}
function setSettingsTab(tab) {
app.settingsTab = tab === 'gree' ? 'gree' : '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;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
}
function renderSystemInfo() {
const host = $('#systemInfo');
if (!host) return;
const elapsed = Math.max(0, Math.floor((Date.now() - Number(app.systemSnapshotAt || Date.now())) / 1000));
const uptime = Number(app.system?.uptime_seconds || 0) + elapsed;
const dbPath = String(app.system?.database || '');
const database = dbPath ? dbPath.split(/[\\/]/).filter(Boolean).pop() : '—';
const ready = !!app.system?.control_ready;
const connected = app.connectionStatus === 'connected';
const items = [
[tr('settings.version'), app.system?.version || '—', 'version'],
[tr('settings.uptime'), formatDuration(uptime), 'uptime'],
[tr('settings.controlEngine'), ready ? tr('settings.ready') : tr('settings.syncing'), ready ? 'ok' : 'warn'],
[tr('settings.websocket'), tr(`status.${app.connectionStatus}`), connected ? 'ok' : 'warn'],
[tr('settings.apiAuth'), app.system?.auth_required ? tr('settings.enabled') : tr('settings.disabled'), 'neutral'],
[tr('settings.httpBind'), app.system?.bind || '—', 'mono'],
[tr('settings.database'), database, 'mono'],
[tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'],
];
host.innerHTML = `<div class="system-panel-head"><div><span class="eyebrow">${esc(tr('settings.systemState'))}</span><h3>${esc(tr('settings.systemStatusTitle'))}</h3><p>${esc(tr('settings.systemStateHint'))}</p></div><span class="system-health ${ready && connected ? 'ok' : 'warn'}">${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}</span></div><div class="system-status-grid">${items.map(([label,value,tone]) => `<div class="system-status-item ${esc(tone)}"><small>${esc(label)}</small><strong>${esc(value)}</strong></div>`).join('')}</div>`;
}
function renderGreeFrameStats() {
const host = $('#greeFrameStats');
if (!host) return;
const total = Number(app.system?.gree_received_frames || 0);
const byDevice = app.system?.gree_received_frames_by_device || {};
const deviceRows = app.devices.filter(device => !device.simulated).map(device => {
const count = Number(byDevice[device.id] || 0);
return `<div class="gree-frame-stat"><small>${esc(device.name || device.id)}</small><strong>${count.toLocaleString(locale())}</strong></div>`;
}).join('');
host.innerHTML = `<div class="gree-frame-stat total"><small>${esc(tr('settings.receivedFramesTotal'))}</small><strong>${total.toLocaleString(locale())}</strong></div>${deviceRows || `<div class="gree-frame-stat"><small>${esc(tr('settings.receivedFramesDevices'))}</small><strong>0</strong></div>`}`;
}
function renderNightSettings() {
if (!app.settings) return;
const form = $('#nightModeForm');
if (!form) return;
form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled;
form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00';
form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00';
form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1);
form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false;
form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false;
markFormClean(form);
}
function renderHomeAssistantSettings() {
if (!app.settings) return;
const form = $('#homeAssistantForm');
if (!form) return;
form.ha_url.value = app.settings.home_assistant?.url || '';
form.ha_token.value = '';
form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken');
form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || '';
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(app.settings.home_assistant?.sensor_stale_after_seconds || 300) / 60)));
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
renderSensorAliases();
renderAccessTokens();
markFormClean(form);
}
function updateInfluxFields() {
const version = $('#settingsForm [name=influx_version]')?.value || '2';
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
}
function updateNotificationFields(){ const provider=$('#settingsForm [name=notifications_provider]')?.value||'pushover'; $$('[data-notification-provider]').forEach(n=>n.hidden=n.dataset.notificationProvider!==provider); }
function logCategory(kind=''){ const prefix=String(kind).split('.')[0]; return ['device','zone','automation','settings'].includes(prefix)?prefix:(['home_assistant','influx','notification'].includes(prefix)?'integration':'system'); }
function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) {
app.debugLines.push({source, kind, message, timestamp, data});
if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160);
renderDebugOverlay();
}
function renderDebugOverlay() {
const overlay = $('#debugOverlay'); if (!overlay) return;
const enabled = !!app.settings?.debug?.overlay_enabled;
overlay.hidden = !enabled;
if (!enabled) return;
if (!['all','requests','gree'].includes(app.debugFilter)) app.debugFilter = 'all';
const status = $('#debugOverlayStatus');
if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly');
$$('[data-debug-filter]', overlay).forEach(button => {
const active = button.dataset.debugFilter === app.debugFilter;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
const host = $('#debugOverlayLines'); if (!host) return;
const visible = app.debugLines.filter(line => {
if (app.debugFilter === 'gree') return line.source === 'GREE';
if (app.debugFilter === 'requests') return line.source === 'HTTP';
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';
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>`;
host.scrollTop = host.scrollHeight;
}
async function loadDebugBacklog() {
if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return;
try {
const data = await api('/api/events?limit=60');
app.debugLines = (data.events || []).reverse().map(item => ({source:'API', kind:item.kind, message:item.message, timestamp:item.timestamp, data:item.metadata})).slice(-120);
app.debugBacklogLoaded = true;
renderDebugOverlay();
} catch (_) {}
}
function formatDuration(seconds) {
const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60);
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
}
setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000);
+289
View File
@@ -0,0 +1,289 @@
function currentSettingsBody() {
const settings = app.settings || {};
const influx = settings.influxdb || {};
const ha = settings.home_assistant || {};
return {
controller_id: settings.controller_id || 'gree-controller',
simulator_enabled: !!settings.simulator_enabled,
poll_interval_seconds: Number(settings.poll_interval_seconds || 15),
zone_interval_seconds: Number(settings.zone_interval_seconds || 5),
discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000),
discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000',
house_mode: settings.house_mode || 'cool',
house_power_enabled: settings.house_power_enabled !== false,
control_strategy: 'setpoint',
outdoor_assist_enabled: !!settings.outdoor_assist_enabled,
history_retention_days: Number(settings.history_retention_days || 30),
history_compaction_enabled: settings.history_compaction_enabled !== false,
event_log_retention_days: Number(settings.event_log_retention_days || 30),
suppress_device_beep: !!settings.suppress_device_beep,
notifications: {
enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover',
pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '',
cooldown_seconds: Number(settings.notifications?.cooldown_seconds || 300), communication_failure_threshold: Number(settings.notifications?.communication_failure_threshold || 3), target_timeout_minutes: Number(settings.notifications?.target_timeout_minutes || 60),
alert_types: {
stale_sensor: settings.notifications?.alert_types?.stale_sensor !== false,
sensor_errors: settings.notifications?.alert_types?.sensor_errors !== false,
communication: settings.notifications?.alert_types?.communication !== false,
target_timeout: settings.notifications?.alert_types?.target_timeout !== false,
automation: settings.notifications?.alert_types?.automation !== false,
control_errors: settings.notifications?.alert_types?.control_errors !== false,
important_events: settings.notifications?.alert_types?.important_events !== false,
other: settings.notifications?.alert_types?.other !== false,
},
},
night_mode: {
enabled: !!settings.night_mode?.enabled,
start_time: settings.night_mode?.start_time || '22:00',
end_time: settings.night_mode?.end_time || '06:00',
max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1),
force_quiet: settings.night_mode?.force_quiet !== false,
use_native_sleep: settings.night_mode?.use_native_sleep !== false,
},
influxdb: {
enabled: !!influx.enabled,
version: String(influx.version || '2'),
url: influx.url || '',
database: influx.database || 'gree_controller',
username: influx.username || '',
password: '',
org: influx.org || '',
bucket: influx.bucket || 'gree_controller',
token: '',
history_threshold_days: Number(influx.history_threshold_days || 30),
},
debug: {
overlay_enabled: !!settings.debug?.overlay_enabled,
gree_frames: !!settings.debug?.gree_frames,
},
home_assistant: {
url: ha.url || '',
token: '',
default_entity_id: ha.default_entity_id || '',
outdoor_entity_id: ha.outdoor_entity_id || '',
sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300),
allow_invalid_tls: !!ha.allow_invalid_tls,
sensor_aliases: {...(ha.sensor_aliases || {})},
},
};
}
function settingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = currentSettingsBody();
body.controller_id = raw.controller_id;
body.simulator_enabled = form.simulator_enabled.checked;
body.poll_interval_seconds = Number(raw.poll_interval_seconds);
body.zone_interval_seconds = Number(raw.zone_interval_seconds);
body.discovery_timeout_ms = Number(raw.discovery_timeout_ms);
body.discovery_broadcast = raw.discovery_broadcast;
body.history_retention_days = Number(raw.history_retention_days);
body.history_compaction_enabled = form.history_compaction_enabled.checked;
body.event_log_retention_days = Number(raw.event_log_retention_days);
body.suppress_device_beep = form.suppress_device_beep.checked;
body.influxdb = {
enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url,
database: raw.influx_database, username: raw.influx_username, password: raw.influx_password,
org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token,
history_threshold_days: Number(raw.influx_threshold_days),
};
body.debug = {overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked};
body.notifications = {
enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider,
pushover_app_token: raw.pushover_app_token || '', pushover_user_key: raw.pushover_user_key || '',
slack_webhook_url: raw.slack_webhook_url || '', discord_webhook_url: raw.discord_webhook_url || '',
cooldown_seconds: Number(raw.notification_cooldown_seconds || 300),
communication_failure_threshold: Number(raw.notification_failure_threshold || 3),
target_timeout_minutes: Number(raw.notification_target_timeout || 60),
alert_types: {
stale_sensor: form.notification_alert_stale_sensor.checked,
sensor_errors: form.notification_alert_sensor_errors.checked,
communication: form.notification_alert_communication.checked,
target_timeout: form.notification_alert_target_timeout.checked,
automation: form.notification_alert_automation.checked,
control_errors: form.notification_alert_control_errors.checked,
important_events: form.notification_alert_important_events.checked,
other: form.notification_alert_other.checked,
},
};
return body;
}
function nightSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = currentSettingsBody();
body.night_mode = {
enabled: form.night_mode_enabled.checked,
start_time: raw.night_mode_start,
end_time: raw.night_mode_end,
max_fan_speed: Number(raw.night_mode_max_fan_speed),
force_quiet: form.night_mode_force_quiet.checked,
use_native_sleep: form.night_mode_native_sleep.checked,
};
return body;
}
function homeAssistantSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = currentSettingsBody();
body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked;
body.home_assistant = {
url: raw.ha_url,
token: raw.ha_token,
default_entity_id: raw.ha_entity_id,
outdoor_entity_id: raw.ha_outdoor_entity_id,
sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))),
allow_invalid_tls: form.ha_allow_invalid_tls.checked,
sensor_aliases: {...(app.sensorAliases || {})},
};
return body;
}
async function saveRuntimeSettings(body, notify = true) {
app.settings = await api('/api/settings', {method:'PUT', body});
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
renderSettings();
renderNightSettings();
renderHomeAssistantSettings();
renderHouseClimate();
renderSimulationModeBanner();
renderSystemInfo();
renderDebugOverlay();
scheduleControlPlanLoad();
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
if (notify) toast(tr('common.saved'));
return app.settings;
}
$('#settingsForm').addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true));
});
$('#nightModeForm')?.addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true));
});
$('#homeAssistantForm')?.addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true));
});
$('#addSensorAlias')?.addEventListener('click', () => {
const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName');
const entity = entityInput.value.trim(), alias = aliasInput.value.trim();
if (!entity || !alias) return toast(tr('settings.aliasRequired'), true);
app.sensorAliases[entity] = alias;
entityInput.value = ''; aliasInput.value = '';
renderSensorAliases(); renderHistoryNavigation();
updateDirtyIndicator($('#homeAssistantForm'));
});
document.addEventListener('input', event => {
const input = event.target.closest('[data-sensor-alias]'); if (!input) return;
const entity = input.dataset.sensorAlias, alias = input.value.trim();
if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity];
updateDirtyIndicator($('#homeAssistantForm'));
});
document.addEventListener('click', event => {
const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return;
delete app.sensorAliases[button.dataset.clearSensorAlias];
renderSensorAliases(); renderHistoryNavigation();
updateDirtyIndicator($('#homeAssistantForm'));
});
$('#saveLogRetention')?.addEventListener('click', async () => {
const days = Number($('#logRetentionDays')?.value || 30);
try {
const result = await api('/api/events/retention', {method:'PUT', body:{days}});
app.settings.event_log_retention_days = result.days;
$('#settingsForm').event_log_retention_days.value = result.days;
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days}));
} catch (error) { toast(error.message, true); }
});
$('#createAccessToken').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}});
if (result.item) app.accessTokens.unshift(result.item);
renderAccessTokens();
$('#generatedAccessToken').value = result.token || '';
openDialog('generatedTokenDialog');
toast(tr('toast.tokenCreated'));
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#copyAccessToken').addEventListener('click', async () => {
const input = $('#generatedAccessToken');
try {
await navigator.clipboard.writeText(input.value);
toast(tr('toast.tokenCopied'));
} catch (_) {
input.select();
document.execCommand('copy');
toast(tr('toast.tokenCopied'));
}
});
$('#haTest').addEventListener('click', async event => {
const form=$('#homeAssistantForm'), button=event.currentTarget;
if (!validateForm(form)) return;
button.disabled = true;
const idle = button.textContent;
button.textContent = tr('settings.testingHa');
try {
await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false);
const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null;
const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}});
markFormClean(form);
toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)}));
} catch(error){presentFormError(form,error);}
finally { button.disabled=false; button.textContent=idle; }
});
$('#simulationRefresh')?.addEventListener('click', async () => {
try { await loadControlPlan(); toast(tr('common.updated')); }
catch (error) { toast(error.message, true); }
});
$('#exportSettings').addEventListener('click', async () => {
try {
const data = await api('/api/settings/export');
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `gree-controller-settings-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href);
toast(tr('toast.exported'));
} catch (error) { toast(error.message, true); }
});
$('#importSettings').addEventListener('click', () => $('#importSettingsFile').click());
$('#importSettingsFile').addEventListener('change', async event => {
const file = event.target.files?.[0]; if (!file) return;
try {
if (!confirm(tr('settings.importConfirm'))) return;
const body = JSON.parse(await file.text());
await api('/api/settings/import', {method:'POST', body});
app.debugBacklogLoaded = false;
await loadBootstrap();
toast(tr('toast.imported'));
} catch (error) { toast(error.message, true); }
finally { event.target.value = ''; }
});
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==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
else if(target.name==='influx_version') updateInfluxFields();
});