v0.5.0
This commit is contained in:
+168
-20
@@ -20,6 +20,7 @@ const app = {
|
||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false,
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
@@ -177,6 +178,8 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.system = data.system || {};
|
||||
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();
|
||||
@@ -190,6 +193,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderHouseClimate();
|
||||
renderControlPlan();
|
||||
renderDevices();
|
||||
renderZones();
|
||||
renderSchedules();
|
||||
@@ -197,6 +201,7 @@ function renderAll() {
|
||||
renderAccessTokens();
|
||||
fillSelects();
|
||||
renderSettings();
|
||||
renderDebugOverlay();
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
@@ -230,6 +235,47 @@ function renderHouseClimate() {
|
||||
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${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 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 houseEvents = (plan.next_events || []).slice(0, 5);
|
||||
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(modeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:(plan.zones || []).filter(zone=>zone.enabled).length, demand:(plan.zones || []).filter(zone=>zone.enabled && 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 zones = (plan.zones || []).map(zone => {
|
||||
const events = (zone.next_events || []).slice(0, 3);
|
||||
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
|
||||
return `<article class="panel plan-card ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(zone.device_name || tr('common.noDevice'))}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(modeLabel(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"><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,5).map(rule=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)} → ${esc(rule.action_device_name || '')}</span></li>`).join('')}</ul></article>` : '';
|
||||
host.innerHTML = house + zones + ruleCard;
|
||||
}
|
||||
|
||||
async function loadControlPlan() {
|
||||
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); }
|
||||
catch (error) { console.warn('Unable to load control plan:', error); }
|
||||
}
|
||||
|
||||
function scheduleControlPlanLoad() {
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
|
||||
}
|
||||
|
||||
function deviceCard(device, detailed = false) {
|
||||
const modes = ['auto','cool','dry','fan','heat'];
|
||||
const fans = [0,1,3,5];
|
||||
@@ -296,8 +342,11 @@ function zoneCard(zone, detailed = true) {
|
||||
const manual = zone.manual_preset || 'auto';
|
||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||
const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl');
|
||||
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''}">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
const enabledControl = detailed
|
||||
? `<span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span>`
|
||||
: `<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'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button>`;
|
||||
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'}">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div>${enabledControl}</div>
|
||||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</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">
|
||||
${['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('')}
|
||||
@@ -327,12 +376,9 @@ function renderSchedules() {
|
||||
}
|
||||
|
||||
function renderAutomations() {
|
||||
const triggerLabel = item => item.trigger_kind === 'time'
|
||||
? tr('automations.triggerAt', {time: item.at_time})
|
||||
: tr(item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow', {temperature: fmtTemp(item.threshold)});
|
||||
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
||||
const actionDevice = app.devices.find(d => d.id === item.action_device_id);
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: triggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||
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: actionDevice?.name || tr('common.noDevice')}))}</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>${item.action.mode ? esc(modeLabel(item.action.mode)) : '—'}</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(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>`;
|
||||
@@ -370,6 +416,23 @@ function renderSettings() {
|
||||
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.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;
|
||||
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');
|
||||
@@ -377,9 +440,46 @@ function renderSettings() {
|
||||
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
|
||||
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||
updateInfluxFields();
|
||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||
}
|
||||
|
||||
function updateInfluxFields() {
|
||||
const version = $('#settingsForm [name=influx_version]')?.value || '2';
|
||||
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
|
||||
}
|
||||
|
||||
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;
|
||||
const status = $('#debugOverlayStatus');
|
||||
if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly');
|
||||
const host = $('#debugOverlayLines'); if (!host) return;
|
||||
host.innerHTML = app.debugLines.length ? app.debugLines.slice(-120).map(line => {
|
||||
const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`;
|
||||
return `<div class="debug-line"><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('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`;
|
||||
@@ -434,7 +534,7 @@ function applyRouteFromLocation() {
|
||||
app.historyDevice = params.get('device') || 'all';
|
||||
app.historySensor = params.get('sensor') || 'all';
|
||||
const hours = params.get('hours');
|
||||
if (hours && ['6','24','168','720'].includes(hours) && $('#historyHours')) $('#historyHours').value = 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'));
|
||||
showView('history', {push:false, scroll:false});
|
||||
return;
|
||||
@@ -463,7 +563,7 @@ async function sendZoneControl(id, 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();
|
||||
renderSummary(); renderZones(); scheduleControlPlanLoad();
|
||||
} catch (error) {
|
||||
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
}
|
||||
@@ -932,16 +1032,20 @@ function connectWebSocket() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); return;
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; 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(); }
|
||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); }
|
||||
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(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); }
|
||||
else if (message.event === 'log.created' && app.currentView === 'logs') loadLogs();
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); 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(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; 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') { 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 (_) {}
|
||||
};
|
||||
}
|
||||
@@ -979,16 +1083,18 @@ document.addEventListener('click', async event => {
|
||||
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(); toast(tr('house.modeUpdated')); }
|
||||
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-preset') {
|
||||
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); toast(tr('house.presetUpdated')); }
|
||||
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); scheduleControlPlanLoad(); toast(tr('house.presetUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
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 === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
|
||||
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);
|
||||
@@ -1096,11 +1202,26 @@ $('#automationForm').addEventListener('submit', async event => {
|
||||
|
||||
function settingsBodyFromForm(form) {
|
||||
const raw=Object.fromEntries(new FormData(form));
|
||||
return {controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,house_mode:app.settings?.house_mode||'cool',control_strategy:'setpoint',outdoor_assist_enabled:form.outdoor_assist_enabled.checked,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,allow_invalid_tls:form.ha_allow_invalid_tls.checked}};
|
||||
return {
|
||||
controller_id:raw.controller_id, simulator_enabled:form.simulator_enabled.checked,
|
||||
poll_interval_seconds:Number(raw.poll_interval_seconds), zone_interval_seconds:Number(raw.zone_interval_seconds),
|
||||
discovery_timeout_ms:Number(raw.discovery_timeout_ms), discovery_broadcast:raw.discovery_broadcast,
|
||||
house_mode:app.settings?.house_mode||'cool', control_strategy:'setpoint', outdoor_assist_enabled:form.outdoor_assist_enabled.checked,
|
||||
history_retention_days:Number(raw.history_retention_days), history_compaction_enabled:form.history_compaction_enabled.checked,
|
||||
suppress_device_beep:form.suppress_device_beep.checked,
|
||||
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),
|
||||
},
|
||||
debug:{overlay_enabled:form.debug_overlay_enabled.checked, gree_frames:form.debug_gree_frames.checked},
|
||||
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,allow_invalid_tls:form.ha_allow_invalid_tls.checked},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSettingsForm(form, notify=true) {
|
||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||
}
|
||||
|
||||
$('#settingsForm').addEventListener('submit', async event => {
|
||||
@@ -1143,11 +1264,38 @@ $('#haTest').addEventListener('click', async () => {
|
||||
} 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();
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', applyRouteFromLocation);
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
</div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="panel house-climate" id="houseClimate"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h2 data-i18n="plan.title">What happens next</h2></div></div>
|
||||
<div class="automation-plan-grid" id="controlPlan"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div>
|
||||
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Direct device control</h2></div></div>
|
||||
@@ -106,6 +108,8 @@
|
||||
<option value="24" selected data-i18n="history.24h">24 hours</option>
|
||||
<option value="168" data-i18n="history.7d">7 days</option>
|
||||
<option value="720" data-i18n="history.30d">30 days</option>
|
||||
<option value="2160" data-i18n="history.90d">90 days</option>
|
||||
<option value="8760" data-i18n="history.1y">1 year</option>
|
||||
</select>
|
||||
<button class="secondary" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
|
||||
</div>
|
||||
@@ -126,6 +130,36 @@
|
||||
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
|
||||
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.metrics">Metrics storage</h3>
|
||||
<p class="field-note wide" data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p>
|
||||
<label><span data-i18n="settings.retentionDays">Local retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
|
||||
<label class="check"><input type="checkbox" name="history_compaction_enabled"> <span data-i18n="settings.compaction">Compact old metrics</span></label>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.greeCommands">GREE commands</h3>
|
||||
<label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label>
|
||||
<p class="field-note wide" data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.influx">Long-term InfluxDB history</h3>
|
||||
<p class="field-note wide" data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p>
|
||||
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
|
||||
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
|
||||
<label><span data-i18n="settings.influxThreshold">Use archive for history older than (days)</span><input type="number" name="influx_threshold_days" min="1" max="3650" value="30"></label>
|
||||
<label class="wide"><span>URL</span><input type="url" name="influx_url" placeholder="http://influxdb:8086"></label>
|
||||
<div class="wide influx-fields" data-influx-fields="1">
|
||||
<label><span data-i18n="settings.influxDatabase">Database</span><input name="influx_database" placeholder="gree_controller"></label>
|
||||
<label><span data-i18n="settings.influxUsername">Username</span><input name="influx_username"></label>
|
||||
<label><span data-i18n="settings.influxPassword">Password</span><input type="password" name="influx_password" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
|
||||
</div>
|
||||
<div class="wide influx-fields" data-influx-fields="2">
|
||||
<label><span data-i18n="settings.influxOrg">Organization</span><input name="influx_org"></label>
|
||||
<label><span data-i18n="settings.influxBucket">Bucket</span><input name="influx_bucket" placeholder="gree_controller"></label>
|
||||
<label><span data-i18n="settings.influxToken">Token</span><input type="password" name="influx_token" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
|
||||
</div>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.debug">On-screen debug</h3>
|
||||
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
|
||||
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
|
||||
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
||||
@@ -143,6 +177,10 @@
|
||||
<div id="accessTokenList" class="token-list"></div>
|
||||
<div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div>
|
||||
</div>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.backup">Configuration backup</h3>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p>
|
||||
<div class="form-actions wide backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
|
||||
</form>
|
||||
<div class="panel system-panel" id="systemInfo"></div>
|
||||
</section>
|
||||
@@ -289,6 +327,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<aside id="debugOverlay" class="debug-overlay" hidden>
|
||||
<div class="debug-overlay-head"><div><strong data-i18n="debug.title">Live debug</strong><small id="debugOverlayStatus"></small></div><button type="button" data-action="debug-clear" data-i18n="debug.clear">Clear</button></div>
|
||||
<div id="debugOverlayLines" class="debug-overlay-lines"></div>
|
||||
</aside>
|
||||
<div id="toastStack" class="toast-stack" role="status" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
|
||||
+43
-1
@@ -158,6 +158,10 @@ h3 { margin-bottom: 10px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
|
||||
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
|
||||
.zone-enable-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
|
||||
.zone-enable-toggle.active { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
|
||||
.zone-enable-toggle span { font-size: 12px; font-weight: 900; }
|
||||
.zone-thermostat.zone-disabled .thermostat-main, .zone-thermostat.zone-disabled .preset-row, .zone-thermostat.zone-disabled .zone-mode-row, .zone-thermostat.zone-disabled .zone-state-line { opacity: .5; }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; }
|
||||
.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; }
|
||||
.card-stat small, .card-stat strong { display: block; }
|
||||
@@ -184,6 +188,10 @@ input:focus, select:focus { border-color: var(--accent); outline: 2px solid colo
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; }
|
||||
.check input { width: 19px; min-height: 19px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; }
|
||||
.influx-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-muted); }
|
||||
.influx-fields[hidden] { display: none; }
|
||||
.influx-fields label:last-child:nth-child(odd) { grid-column: 1/-1; }
|
||||
.backup-actions { align-items: center; }
|
||||
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text); }
|
||||
.log-list { display: grid; gap: 2px; padding: 8px; }
|
||||
@@ -296,6 +304,23 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.outside-pill small, .outside-pill strong { display: block; }
|
||||
.outside-pill small { color: var(--muted); font-size: 10px; }
|
||||
.outside-pill strong { margin-top: 3px; font-size: 18px; }
|
||||
.automation-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 12px; margin-bottom: 34px; }
|
||||
.plan-card { display: grid; align-content: start; gap: 11px; min-height: 190px; padding: 17px; }
|
||||
.plan-card.disabled { opacity: .65; }
|
||||
.plan-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.plan-card-head h3 { margin: 2px 0 0; font-size: 19px; }
|
||||
.plan-card > p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
|
||||
.plan-temp { display: flex; align-items: baseline; gap: 9px; }
|
||||
.plan-temp > span { color: var(--muted); font-size: 20px; }
|
||||
.plan-temp > b { color: var(--muted); font-weight: 500; }
|
||||
.plan-temp > strong { font-size: 28px; letter-spacing: -.04em; }
|
||||
.plan-temp small { margin-left: 2px; color: var(--muted); font-size: 11px; }
|
||||
.plan-events { display: grid; gap: 6px; margin: 0; padding: 10px 0 0; border-top: 1px solid var(--line); list-style: none; }
|
||||
.plan-events li { display: grid; grid-template-columns: 82px 1fr; gap: 8px; color: var(--text-soft); font-size: 11px; line-height: 1.35; }
|
||||
.plan-events time { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.plan-events .muted { display: block; color: var(--muted); }
|
||||
.plan-house { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
|
||||
.plan-loading { grid-column: 1/-1; padding: 18px; color: var(--muted); }
|
||||
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.house-mode-row button { min-height: 44px; color: var(--muted); background: var(--surface-muted); }
|
||||
.house-mode-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
|
||||
@@ -409,13 +434,30 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.toast-copy strong { margin-bottom: 2px; font-size: 12px; }
|
||||
.toast-copy span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||
.toast-close { width: 28px; height: 28px; padding: 0; border-radius: 50%; color: var(--muted); background: transparent; font-size: 19px; }
|
||||
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--accent); transform-origin: left; animation: toast-progress 3.6s linear forwards; }
|
||||
.toast-progress { position: absolute; right: 11px; bottom: 3px; left: 11px; height: 2px; border-radius: 99px; background: var(--accent); transform-origin: left; animation: toast-progress 3.6s linear forwards; }
|
||||
.toast-item.error .toast-progress { background: var(--danger); animation-duration: 5.2s; }
|
||||
@keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
|
||||
.debug-overlay { position: fixed; z-index: 110; right: 18px; bottom: 92px; width: min(680px, calc(100vw - 36px)); max-height: min(46vh, 440px); overflow: hidden; border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 18px; background: color-mix(in srgb, var(--surface) 96%, transparent); box-shadow: 0 18px 60px rgba(0,0,0,.22); backdrop-filter: blur(18px); }
|
||||
.debug-overlay[hidden] { display: none; }
|
||||
.debug-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
||||
.debug-overlay-head > div { display: grid; gap: 2px; }
|
||||
.debug-overlay-head strong { font-size: 12px; }
|
||||
.debug-overlay-head small { color: var(--muted); font-size: 10px; }
|
||||
.debug-overlay-head button { padding: 6px 9px; color: var(--muted); background: var(--surface-muted); font-size: 10px; }
|
||||
.debug-overlay-lines { max-height: min(38vh, 360px); overflow: auto; padding: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
.debug-line { display: grid; grid-template-columns: 70px 46px 1fr; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); font-size: 10px; line-height: 1.35; }
|
||||
.debug-line time, .debug-line b { color: var(--muted); font-weight: 600; }
|
||||
.debug-line span { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.debug-empty { padding: 18px; color: var(--muted); text-align: center; font-size: 11px; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.custom-chart-add, .custom-chart-save { align-items: stretch; flex-direction: column; }
|
||||
.toast-stack { right: 12px; bottom: 88px; width: calc(100vw - 24px); }
|
||||
.debug-overlay { right: 12px; bottom: 88px; width: calc(100vw - 24px); }
|
||||
.debug-line { grid-template-columns: 58px 38px 1fr; }
|
||||
.influx-fields { grid-template-columns: 1fr; }
|
||||
.influx-fields label:last-child:nth-child(odd) { grid-column: auto; }
|
||||
}
|
||||
|
||||
.history-chart-card canvas { width: 100%; min-width: 720px; }
|
||||
|
||||
Reference in New Issue
Block a user