v0.8.20
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ const app = {
|
||||
languages: [], translations: {}, locales: {},
|
||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, zoneTemperatureTimers: {},
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
};
|
||||
|
||||
+86
-37
@@ -79,6 +79,36 @@ function zonePresetLabel(preset) {
|
||||
return tr(key) === key ? (preset || 'comfort') : tr(key);
|
||||
}
|
||||
|
||||
function groupControlHue(groupId) {
|
||||
let hash=0;
|
||||
for(const char of String(groupId || 'group')) hash=((hash*31)+char.charCodeAt(0))>>>0;
|
||||
return (hash % 300) + 20;
|
||||
}
|
||||
|
||||
function groupControlStyle(group) {
|
||||
return group ? ` style="--group-control-color:hsl(${groupControlHue(group.id)} 72% 48%)"` : '';
|
||||
}
|
||||
|
||||
function groupControlForZone(zone) {
|
||||
if(app.settings?.house_power_enabled === false || zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
|
||||
const source=String(zone?.control_source || '');
|
||||
if(!source.startsWith('group:')) return null;
|
||||
const groupId=source.slice(6);
|
||||
return app.groups.find(group => group.id===groupId && group.power_enabled !== false && (group.zone_ids || []).includes(zone.id)) || null;
|
||||
}
|
||||
|
||||
function blockingGroupsForZone(zone) {
|
||||
if (app.settings?.house_power_enabled === false || zone?.local_thermostat_power === true) return [];
|
||||
const zoneId=zone?.id || zone?.zone_id;
|
||||
if(!zoneId) return [];
|
||||
return app.groups.filter(group => group.power_enabled === false && (group.zone_ids || []).includes(zoneId));
|
||||
}
|
||||
|
||||
function zoneLockoutActive(zone) {
|
||||
const until=zone?.lockout_until ? new Date(zone.lockout_until).getTime() : NaN;
|
||||
return Number.isFinite(until) && until>Date.now();
|
||||
}
|
||||
|
||||
function zoneControlOwnerLabel(zone) {
|
||||
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
|
||||
const source=zone.control_source || '';
|
||||
@@ -89,24 +119,34 @@ function zoneControlOwnerLabel(zone) {
|
||||
return tr('zones.ownerExternal');
|
||||
}
|
||||
if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat');
|
||||
if(source.startsWith('group:')) return tr('zones.ownerGroup');
|
||||
return tr('zones.ownerAutomation');
|
||||
}
|
||||
|
||||
function zoneControlOwnerMeta(zone) {
|
||||
const parts=[];
|
||||
const controlGroup=groupControlForZone(zone);
|
||||
if(controlGroup) parts.push(`${tr('groups.group')}: ${controlGroup.name}`);
|
||||
if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
|
||||
if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
|
||||
if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function zoneRuntimeStatusLabel(zone, effectiveMode) {
|
||||
function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) {
|
||||
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
|
||||
if (!zone.enabled) return tr('common.disabled');
|
||||
if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff');
|
||||
const blockers=blockingGroupsForZone(zone);
|
||||
if (blockers.length) return tr('zones.waitingGroup', {groups:blockers.map(group=>group.name).join(', ')});
|
||||
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
|
||||
if (device?.enabled === false) return tr('zones.waitingDeviceDisabled');
|
||||
if (device && (!device.online || Number(device.communication_failures || 0) > 0)) return tr('zones.waitingOffline');
|
||||
if (zone.current_temperature == null) return tr('zones.noMeasurement');
|
||||
return zone.demand ? tr('zones.requesting') : tr('zones.satisfied');
|
||||
if (zone.demand && zoneLockoutActive(zone)) return tr('zones.waitingLockout');
|
||||
if (zone.demand && device && !device.power) return tr('zones.waitingStart');
|
||||
if (zone.demand && device?.power && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode) return tr('zones.waitingMode');
|
||||
return zone.demand ? tr('zones.runningDemand') : tr('zones.satisfied');
|
||||
}
|
||||
|
||||
function zoneCard(zone, detailed = true) {
|
||||
@@ -125,6 +165,15 @@ function zoneCard(zone, detailed = true) {
|
||||
const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`;
|
||||
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
|
||||
const deviceManualOverride = zone.device_manual_override === true;
|
||||
const controlGroup = groupControlForZone(zone);
|
||||
const blockingGroups = blockingGroupsForZone(zone);
|
||||
const deviceUnavailable = !!device && (device.enabled === false || !device.online || Number(device.communication_failures || 0) > 0);
|
||||
const lockoutWaiting = zone.demand && zoneLockoutActive(zone);
|
||||
const startWaiting = zone.demand && !!device && !deviceUnavailable && !device.power;
|
||||
const modeWaiting = zone.demand && !!device?.power && !deviceUnavailable && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode;
|
||||
const groupBlockedWaiting = blockingGroups.length > 0;
|
||||
const waitingDemand = lockoutWaiting || startWaiting || modeWaiting || groupBlockedWaiting || (zone.demand && deviceUnavailable);
|
||||
const workingDemand = zone.demand && !!device?.power && !deviceUnavailable && !waitingDemand;
|
||||
const localThermostatPower = zone.local_thermostat_power;
|
||||
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
|
||||
const temporarySession = zone.temporary_quick_thermostat || null;
|
||||
@@ -159,11 +208,11 @@ function zoneCard(zone, detailed = true) {
|
||||
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)}">
|
||||
return `<article class="list-card zone-config-card ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-config="${esc(zone.id)}"${groupControlStyle(controlGroup)}>
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
|
||||
<div class="zone-config-status">
|
||||
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
|
||||
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
|
||||
<div class="zone-config-runtime"><span class="badge ${workingDemand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
|
||||
</div>
|
||||
<div class="zone-config-grid">
|
||||
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
|
||||
@@ -177,14 +226,14 @@ function zoneCard(zone, detailed = true) {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
|
||||
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-card="${esc(zone.id)}"${groupControlStyle(controlGroup)}>
|
||||
<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 editable-target" data-temperature-kind="zone" data-id="${esc(zone.id)}" data-value="${Number.isFinite(target) ? target : ''}" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||||
<div class="preset-row quick-control-row quick-control-row-4">
|
||||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||||
</div>
|
||||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(override)}</span></div>
|
||||
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
|
||||
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
|
||||
${manualTakeover}
|
||||
</article>`;
|
||||
@@ -232,15 +281,20 @@ function groupCard(group, detailed = false) {
|
||||
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
|
||||
const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value);
|
||||
const fallbackTarget = Number(state.zones[0]?.manual_setpoint ?? state.zones[0]?.effective_setpoint ?? state.zones[0]?.setpoint ?? 23);
|
||||
const customTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23);
|
||||
const customEditorVisible = state.preset === 'custom';
|
||||
return `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div></div>
|
||||
const currentCustomTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23);
|
||||
const customDraft = app.groupCustomDrafts?.[group.id];
|
||||
const draftTarget = Number(customDraft?.value);
|
||||
const customTarget = Number.isFinite(draftTarget) ? draftTarget : currentCustomTarget;
|
||||
const customEditorVisible = state.preset === 'custom' || customDraft?.open === true;
|
||||
const customDraftPending = customDraft?.open === true && state.preset !== 'custom';
|
||||
const groupIsControlSource = powerEnabled && state.zones.some(zone => String(zone.control_source || '') === `group:${group.id}` && !zone.device_manual_override && zone.local_thermostat_power == null);
|
||||
return `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'} ${groupIsControlSource ? 'group-controlled' : ''}"${groupControlStyle(groupIsControlSource ? group : null)}>
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div>${groupIsControlSource ? `<span class="group-control-badge">${esc(tr('groups.activeControl'))}</span>` : ''}</div>
|
||||
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
|
||||
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : ''}" data-action="group-custom-open" data-id="${esc(group.id)}">${esc(tr('preset.custom'))}</button></div>
|
||||
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button></div></label></div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}">${esc(tr('preset.custom'))}</button></div>
|
||||
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
|
||||
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
|
||||
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
@@ -257,34 +311,29 @@ function renderGroups() {
|
||||
}
|
||||
|
||||
async function sendGroupControl(id, patch) {
|
||||
const sequence = (app.groupControlSeq[id] || 0) + 1;
|
||||
app.groupControlSeq[id] = sequence;
|
||||
const previous = app.groupControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => {}).then(() =>
|
||||
api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch})
|
||||
);
|
||||
app.groupControlQueue[id] = request;
|
||||
try {
|
||||
const result = await request;
|
||||
if (app.groupControlSeq[id] !== sequence) return;
|
||||
if (result.group) {
|
||||
const index = app.groups.findIndex(group => group.id === result.group.id);
|
||||
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
|
||||
}
|
||||
(result.zones || []).forEach(zone => {
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
await enqueueClimateControlTask(async () => {
|
||||
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||||
if (result.group) {
|
||||
const index = app.groups.findIndex(group => group.id === result.group.id);
|
||||
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
|
||||
}
|
||||
(result.zones || []).forEach(zone => {
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
});
|
||||
(result.devices || []).forEach(updateDevice);
|
||||
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
|
||||
if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id];
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('groups.partial', {count: failed}), true);
|
||||
else toast(tr('groups.controlUpdated'));
|
||||
return result;
|
||||
});
|
||||
(result.devices || []).forEach(updateDevice);
|
||||
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('groups.partial', {count: failed}), true);
|
||||
else toast(tr('groups.controlUpdated'));
|
||||
} catch (error) {
|
||||
if (app.groupControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
} finally {
|
||||
if (app.groupControlQueue[id] === request) delete app.groupControlQueue[id];
|
||||
await loadBootstrap();
|
||||
toast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-16
@@ -73,29 +73,35 @@ document.addEventListener('click', async event => {
|
||||
if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) {
|
||||
return toast(tr('devices.disabledZoneTechnicalOnly'), true);
|
||||
}
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
|
||||
if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)});
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({power:!current?.power}));
|
||||
if (action === 'temperature' && device) return sendDeviceCommand(device.id, current => ({target_temperature:clamp(Number(current?.target_temperature ?? device.target_temperature)+Number(button.dataset.delta),8,30)}));
|
||||
if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true});
|
||||
if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)});
|
||||
if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]});
|
||||
if (action === 'toggle' && device) return sendDeviceCommand(device.id, current => ({[button.dataset.field]:!current?.[button.dataset.field]}));
|
||||
if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
|
||||
if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
|
||||
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
|
||||
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
|
||||
if (action === 'house-mode') {
|
||||
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
try {
|
||||
await enqueueClimateControlTask(async () => {
|
||||
await api('/api/house/control',{method:'POST', body:{mode:button.dataset.value}});
|
||||
await loadBootstrap();
|
||||
});
|
||||
toast(tr('house.modeUpdated'));
|
||||
} catch(error){ toast(error.message,true); }
|
||||
return;
|
||||
}
|
||||
if (action === 'house-power') {
|
||||
const power = button.dataset.value === 'true';
|
||||
if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return;
|
||||
try {
|
||||
button.disabled = true;
|
||||
const result = await api('/api/house/power',{method:'POST',body:{power}});
|
||||
app.settings = result.settings || app.settings;
|
||||
app.devices = result.devices || app.devices;
|
||||
app.groups = result.groups || app.groups;
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const result = await enqueueClimateControlTask(async () => {
|
||||
const response = await api('/api/house/power',{method:'POST', body:{power}});
|
||||
await loadBootstrap();
|
||||
return response;
|
||||
});
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('house.powerPartial', {count: failed}), true);
|
||||
else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone'));
|
||||
@@ -105,20 +111,31 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (action === 'house-preset') {
|
||||
try {
|
||||
const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}});
|
||||
app.settings=result.settings||app.settings; app.zones=result.zones||app.zones; app.devices=result.devices||app.devices;
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const result = await enqueueClimateControlTask(async () => {
|
||||
const response = await api('/api/house/preset',{method:'POST', body:{preset:button.dataset.value}});
|
||||
await loadBootstrap();
|
||||
return response;
|
||||
});
|
||||
const failed=Array.isArray(result.failed)?result.failed.length:0;
|
||||
if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated'));
|
||||
}
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
catch(error){ toast(error.message,true); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'});
|
||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'group-preset') { delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); }
|
||||
if (action === 'group-custom-open') {
|
||||
const editor=button.closest('.group-card')?.querySelector('[data-group-custom-editor]');
|
||||
if(editor){ editor.hidden=false; const input=editor.querySelector('[data-group-custom-temperature]'); input?.focus(); input?.select(); }
|
||||
const input=editor?.querySelector('[data-group-custom-temperature]');
|
||||
const value=parseDecimal(input?.value);
|
||||
app.groupCustomDrafts[button.dataset.id]={open:true,value:Number.isFinite(value)?value:23};
|
||||
if(editor){ editor.hidden=false; input?.focus(); input?.select(); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-cancel') {
|
||||
delete app.groupCustomDrafts[button.dataset.id];
|
||||
renderGroups();
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-temperature') {
|
||||
@@ -194,6 +211,14 @@ $('#themeSelect')?.addEventListener('change', event => setTheme(event.target.val
|
||||
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
|
||||
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||
document.addEventListener('input', event => {
|
||||
const input=event.target.closest?.('[data-group-custom-temperature]');
|
||||
if(!input) return;
|
||||
const id=input.dataset.groupCustomTemperature;
|
||||
if(!id) return;
|
||||
app.groupCustomDrafts[id]={open:true,value:input.value};
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const input=event.target.closest?.('[data-group-custom-temperature]');
|
||||
if(!input || event.key!=='Enter') return;
|
||||
|
||||
+22
-2
@@ -34,11 +34,31 @@ function showHistoryTab(tab, {push=true, load=true}={}) {
|
||||
if (load) loadHistory();
|
||||
}
|
||||
|
||||
async function sendDeviceCommand(id, command) {
|
||||
async function sendDeviceCommand(id, commandOrFactory) {
|
||||
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
||||
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});
|
||||
});
|
||||
app.deviceControlQueue[id] = request;
|
||||
try {
|
||||
const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command});
|
||||
const device = await request;
|
||||
updateDevice(device); renderAll();
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally {
|
||||
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueClimateControlTask(task) {
|
||||
const previous = app.climateControlQueue || Promise.resolve();
|
||||
const request = previous.catch(() => {}).then(task);
|
||||
app.climateControlQueue = request;
|
||||
request.finally(() => {
|
||||
if (app.climateControlQueue === request) app.climateControlQueue = null;
|
||||
}).catch(() => {});
|
||||
return request;
|
||||
}
|
||||
|
||||
function updateDevice(device) {
|
||||
|
||||
Reference in New Issue
Block a user