This commit is contained in:
Mateusz Gruszczyński
2026-08-28 09:44:39 +02:00
parent 016aac0f8d
commit d159795267
16 changed files with 782 additions and 48 deletions
+174 -5
View File
@@ -58,6 +58,16 @@ const formatCountdown = seconds => {
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);
@@ -66,6 +76,56 @@ function updateLocalThermostatCountdowns() {
: 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 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.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 haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
function updateConnectionIndicator(status) {
@@ -756,11 +816,15 @@ function zoneCard(zone, detailed = true) {
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
@@ -768,21 +832,24 @@ function zoneCard(zone, detailed = true) {
: (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
: 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 = `<div class="zone-local-thermostat-power"><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}>${esc(tr(localRequestedOn ? 'zones.turnThermostatOff' : 'zones.turnThermostatOn'))}</button></div>`;
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" 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><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<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>
@@ -800,8 +867,7 @@ function zoneCard(zone, detailed = true) {
}
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><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></div>
${localThermostatPowerControl}
<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('')}
@@ -1314,6 +1380,64 @@ function populateZone(id) {
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 kind = form.finish_kind.value;
const duration = $('#temporaryDurationFields');
const until = $('#temporaryUntilFields');
const temperature = $('#temporaryTemperatureFields');
const schedule = $('#temporaryScheduleFields');
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.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.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000));
const session = zone.temporary_quick_thermostat;
if (session) {
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.expires_at) {
form.until.value = dateTimeLocalValue(session.expires_at);
if (session.finish_kind === 'duration') form.duration_minutes.value = Math.max(1, Math.ceil((new Date(session.expires_at).getTime() - Date.now()) / 60000));
}
if (session.safety_expires_at) {
form.max_duration_minutes.value = Math.max(1, Math.ceil((new Date(session.safety_expires_at).getTime() - Date.now()) / 60000));
} else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) {
form.max_duration_minutes.value = '';
}
}
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();
@@ -1824,6 +1948,7 @@ document.addEventListener('click', async event => {
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});
@@ -1984,6 +2109,49 @@ $('#zoneForm').addEventListener('submit', async event => {
catch(error){toast(error.message,true);}
});
$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields);
$$('[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 request = {finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)};
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);
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;
}
try {
$('#temporaryThermostatSubmit').disabled = true;
const zone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}});
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStarted'));
} catch (error) { toast(error.message, true); }
finally { $('#temporaryThermostatSubmit').disabled = false; }
});
$('#groupForm')?.addEventListener('submit', async event => {
event.preventDefault();
const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
@@ -2293,4 +2461,5 @@ async function startApplication() {
}
setInterval(updateLocalThermostatCountdowns, 1000);
setInterval(updateTemporaryThermostatCountdowns, 1000);
startApplication().catch(error => console.error('Application startup failed:', error));