This commit is contained in:
Mateusz Gruszczyński
2026-09-01 15:38:46 +02:00
parent 1392114c1e
commit 3000dbaf01
26 changed files with 7520 additions and 2299 deletions
+36 -38
View File
@@ -1,5 +1,5 @@
function setDashboardTab(tab, {scroll=true}={}) {
const next = ['main','thermostats','manual'].includes(tab) ? tab : 'main';
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;
@@ -11,23 +11,23 @@ function setDashboardTab(tab, {scroll=true}={}) {
panel.classList.toggle('active', active);
panel.hidden = !active;
});
if (scroll && app.currentView === 'dashboard') window.scrollTo({top:0, behavior:'smooth'});
if (scroll && app.currentView === 'dashboard') window.scrollTo({ top: 0, behavior: 'smooth' });
}
function showView(name, {push=true, scroll=true}={}) {
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))));
$$('.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 (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}={}) {
function showHistoryTab(tab, { push = true, load = true } = {}) {
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
renderHistoryNavigation();
if (push) updateBrowserUrl(currentHistoryPath());
@@ -36,10 +36,10 @@ function showHistoryTab(tab, {push=true, load=true}={}) {
async function sendDeviceCommand(id, commandOrFactory) {
const previous = app.deviceControlQueue[id] || Promise.resolve();
const request = previous.catch(() => {}).then(() => {
const request = previous.catch(() => { }).then(() => {
const current = app.devices.find(device => device.id === id);
const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory;
return api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command});
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body: command });
});
app.deviceControlQueue[id] = request;
try {
@@ -53,11 +53,11 @@ async function sendDeviceCommand(id, commandOrFactory) {
function enqueueClimateControlTask(task) {
const previous = app.climateControlQueue || Promise.resolve();
const request = previous.catch(() => {}).then(task);
const request = previous.catch(() => { }).then(task);
app.climateControlQueue = request;
request.finally(() => {
if (app.climateControlQueue === request) app.climateControlQueue = null;
}).catch(() => {});
}).catch(() => { });
return request;
}
@@ -68,13 +68,13 @@ function updateDevice(device) {
function enqueueZoneControlRequest(id, patch) {
const previous = app.zoneControlQueue[id] || Promise.resolve();
const request = previous.catch(() => {}).then(() =>
api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch})
const request = previous.catch(() => { }).then(() =>
api(`/api/zones/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch })
);
app.zoneControlQueue[id] = request;
request.finally(() => {
if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id];
}).catch(() => {});
}).catch(() => { });
return request;
}
@@ -82,7 +82,7 @@ async function sendZoneLocalPower(id, power) {
const sequence = (app.zoneControlSeq[id] || 0) + 1;
app.zoneControlSeq[id] = sequence;
try {
const zone = await enqueueZoneControlRequest(id, {power});
const zone = await enqueueZoneControlRequest(id, { power });
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);
@@ -110,7 +110,7 @@ async function sendZoneControl(id, patch) {
async function cancelCompressorTask(id) {
try {
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, {method:'POST'});
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, { method: 'POST' });
if (result.zone) {
const index = app.zones.findIndex(item => item.id === result.zone.id);
if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone);
@@ -122,17 +122,17 @@ async function cancelCompressorTask(id) {
async function cancelAllCompressorTasks() {
try {
const result = await api('/api/compressor-queue/cancel-all', {method:'POST'});
const result = await api('/api/compressor-queue/cancel-all', { method: 'POST' });
(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);
});
renderAll(); scheduleControlPlanLoad();
toast(tr('zones.queuedCancelledAll', {count:Number(result.cancelled || 0)}));
toast(tr('zones.queuedCancelledAll', { count: Number(result.cancelled || 0) }));
} catch (error) { toast(error.message, true); }
}
function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
function queueZoneTemperature(zone, value, { snapToHalf = true } = {}) {
const clamped = clamp(value, 8, 30);
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
zone.manual_setpoint = next;
@@ -140,7 +140,7 @@ function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
zone.effective_setpoint = next;
renderZones();
clearTimeout(app.zoneTemperatureTimers[zone.id]);
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160);
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, { setpoint: next }), 160);
}
function beginInlineTemperatureEdit(target) {
@@ -168,23 +168,23 @@ function beginInlineTemperatureEdit(target) {
finished = true;
const parsed = parseDecimal(input.value);
if (!commit || !Number.isFinite(parsed) || parsed < 8 || parsed > 30) {
if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', {min:8, max:30}), true);
if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', { min: 8, max: 30 }), true);
renderAll();
return;
}
if (kind === 'zone') {
const zone = app.zones.find(item => item.id === id);
if (zone) queueZoneTemperature(zone, parsed, {snapToHalf:false});
if (zone) queueZoneTemperature(zone, parsed, { snapToHalf: false });
return;
}
const device = app.devices.find(item => item.id === id);
if (device) await sendDeviceCommand(id, {target_temperature:Math.round(clamp(parsed, 8, 30))});
if (device) await sendDeviceCommand(id, { target_temperature: Math.round(clamp(parsed, 8, 30)) });
};
input.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
if (event.key === 'Escape') { event.preventDefault(); finished = true; renderAll(); }
});
input.addEventListener('blur', () => finish(true), {once:true});
input.addEventListener('blur', () => finish(true), { once: true });
}
function showDiscoveryNames(ids) {
@@ -208,8 +208,8 @@ function populateDeviceRename(id) {
}
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')); }
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); }
}
@@ -223,7 +223,7 @@ function openDialog(id) {
}
}
function updateZoneHysteresisFields({syncFromCommon=false}={}) {
function updateZoneHysteresisFields({ syncFromCommon = false } = {}) {
const form = $('#zoneForm');
if (!form) return;
const separate = !!form.separate_hysteresis?.checked;
@@ -266,11 +266,11 @@ function updateAutomationTargetFields() {
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;
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 = '';
if (groupTarget && ['dry', 'fan'].includes(form.action_mode.value)) form.action_mode.value = '';
}
function populateZone(id) {
@@ -280,7 +280,7 @@ function populateZone(id) {
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; });
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);
@@ -303,7 +303,7 @@ 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())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
function updateTemporaryThermostatFields() {
@@ -320,7 +320,7 @@ function updateTemporaryThermostatFields() {
startAt.hidden = startKind !== 'at';
duration.hidden = kind !== 'duration';
until.hidden = kind !== 'until';
temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind);
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';
@@ -368,13 +368,11 @@ function populateTemporaryThermostat(id) {
} 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)) {
} 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;
@@ -393,7 +391,7 @@ function populateTemporaryThermostat(id) {
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]; });
['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');
@@ -402,7 +400,7 @@ function populateSchedule(id) {
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]; });
['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;