This commit is contained in:
Mateusz Gruszczyński
2026-08-23 23:20:00 +02:00
parent 8bb12a1782
commit b23578a0c8
34 changed files with 2300 additions and 447 deletions
+123 -38
View File
@@ -13,7 +13,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
const app = {
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {},
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
@@ -39,7 +39,7 @@ function applyTheme() {
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
const meta = $('#themeColorMeta');
if (meta) meta.content = resolved === 'light' ? '#f5f7f6' : '#0a1110';
if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515';
const select = $('#themeSelect');
if (select) select.value = app.theme;
drawCurrentChartIfVisible();
@@ -166,6 +166,7 @@ async function loadBootstrap(showMessage = false) {
app.accessTokens = data.access_tokens || [];
app.settings = data.settings || null;
app.system = data.system || {};
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
renderAll();
if (showMessage) toast(tr('common.updated'));
if ($('#tokenDialog').open) $('#tokenDialog').close();
@@ -179,6 +180,7 @@ async function loadBootstrap(showMessage = false) {
function renderAll() {
renderSummary();
renderHouseClimate();
renderDevices();
renderZones();
renderSchedules();
@@ -206,6 +208,19 @@ function renderSummary() {
].map(([label,value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
}
function renderHouseClimate() {
const node = $('#houseClimate'); if (!node || !app.settings) return;
const mode = app.settings.house_mode || 'cool';
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
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"><span class="mode-indicator"></span>${esc(tr('mode.cool'))}</button>
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat"><span class="mode-indicator"></span>${esc(tr('mode.heat'))}</button>
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off"><span class="mode-indicator"></span>${esc(tr('mode.off'))}</button>
</div>
<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 deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
@@ -257,24 +272,38 @@ function zoneControlSourceLabel(source) {
})[source] || source || tr('zones.sourceUnavailable');
}
function zonePresetLabel(preset) {
const key = `preset.${preset || 'comfort'}`;
return tr(key) === key ? (preset || 'comfort') : tr(key);
}
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 target = Number(zone.effective_setpoint ?? zone.setpoint);
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>
<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('')}
</div>
<div class="mode-row zone-mode-row"><button class="${mode==='house'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house">${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>${zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied'))}</span><span>${esc(override)}</span></div>
${detailed ? `<div class="sensor-detail">${esc(sensorDetails)}</div><div class="card-footer"><small>${esc(tr('zones.quickHintSmart'))}</small><div class="card-menu"><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>`;
}
function renderZones() {
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => {
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)}`;
return `<article class="list-card">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(zone.setpoint)}</strong></div><div class="card-stat"><small>${esc(tr('zones.demand'))}</small><strong>${esc(tr(zone.demand ? 'common.on' : 'common.off'))}</strong></div></div>
<div class="zone-quick-control">
<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(zone.setpoint).toFixed(1)}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div>
<div class="mode-row"><button class="${zone.mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${zone.mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
</div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
<div class="card-footer"><small>${esc(tr('zones.quickHint'))}</small><div class="card-menu"><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>`;
}).join('') : `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
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;
}
function renderSchedules() {
@@ -283,7 +312,7 @@ function renderSchedules() {
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('common.target'))}</small><strong>${fmtTemp(item.setpoint)}</strong></div></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>`;
}
@@ -309,6 +338,8 @@ function fillSelects() {
});
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 history = $('#historyDevice');
if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; }
}
@@ -336,6 +367,9 @@ function renderSettings() {
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_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
$('#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>`;
}
@@ -414,22 +448,36 @@ function updateZoneSensorFields() {
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 populateZone(id) {
const item = app.zones.find(v => v.id === id); if (!item) return;
const form = $('#zoneForm'); form.reset(); fillSelects();
Object.entries(item).forEach(([key,value]) => { if (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');
}
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'].forEach(key => form.elements[key].value = item[key]);
['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)));
openDialog('scheduleDialog');
updateSchedulePresetField(); openDialog('scheduleDialog');
}
function populateAutomation(id) {
@@ -510,14 +558,15 @@ 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; 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(); 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(); renderZones(); }
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); }
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();
} catch (_) {}
};
@@ -530,8 +579,18 @@ document.addEventListener('click', async event => {
return;
}
if (button.dataset.go) { $('#moreDialog').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') updateZoneSensorFields(); openDialog(button.dataset.open); return; }
if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); openDialog(button.dataset.open); return; }
if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); 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 (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
@@ -543,8 +602,17 @@ document.addEventListener('click', async event => {
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 === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) return sendZoneControl(zone.id,{setpoint:clamp(Number(zone.setpoint)+Number(button.dataset.delta),8,30)}); }
if (action === 'house-mode') {
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); 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')); }
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.effective_setpoint ?? zone.setpoint); return sendZoneControl(zone.id,{setpoint:clamp(base+Number(button.dataset.delta),8,30)}); } }
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 === '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);
@@ -576,6 +644,7 @@ $('#logsRefresh').addEventListener('click', loadLogs);
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
$('#tokenForm').addEventListener('submit', event => {
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
@@ -621,7 +690,15 @@ $('#deviceForm').addEventListener('submit', async event => {
$('#zoneForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id; const body={name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,mode:raw.mode,setpoint:Number(raw.setpoint),hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),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:Number(raw.max_sensor_difference)};
const id=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:Number(raw.setpoint),
cool_comfort_setpoint:Number(raw.cool_comfort_setpoint),cool_sleep_setpoint:Number(raw.cool_sleep_setpoint),cool_away_setpoint:Number(raw.cool_away_setpoint),
heat_comfort_setpoint:Number(raw.heat_comfort_setpoint),heat_sleep_setpoint:Number(raw.heat_sleep_setpoint),heat_away_setpoint:Number(raw.heat_away_setpoint),
hysteresis:Number(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:Number(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:Number(raw.max_sensor_difference)
};
try { 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')); }
catch(error){toast(error.message,true);}
});
@@ -629,7 +706,7 @@ $('#zoneForm').addEventListener('submit', async event => {
$('#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,setpoint:Number(raw.setpoint)};
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:Number(raw.setpoint||23)};
try { 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')); }
catch(error){toast(error.message,true);}
});
@@ -642,11 +719,17 @@ $('#automationForm').addEventListener('submit', async event => {
catch(error){toast(error.message,true);}
});
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}};
}
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;
}
$('#settingsForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const body={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,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id}};
try { app.settings=await api('/api/settings',{method:'PUT',body}); renderSettings(); toast(tr('common.saved')); }
catch(error){toast(error.message,true);}
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);}
});
$('#createAccessToken').addEventListener('click', async event => {
@@ -677,11 +760,12 @@ $('#copyAccessToken').addEventListener('click', async () => {
$('#haTest').addEventListener('click', async () => {
const form=$('#settingsForm');
if (form.ha_token.value || form.ha_url.value !== (app.settings?.home_assistant?.url || '')) {
form.requestSubmit(); await new Promise(resolve=>setTimeout(resolve,250));
}
try { const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:form.ha_entity_id.value||null}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); }
catch(error){toast(error.message,true);}
try {
await saveSettingsForm(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}});
toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)}));
} catch(error){toast(error.message,true);}
});
window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); });
@@ -693,6 +777,7 @@ async function startApplication() {
await loadLanguages();
applyTranslations();
updateZoneSensorFields();
updateSchedulePresetField();
await loadBootstrap();
}