v0.8.19
This commit is contained in:
+1
-1
@@ -437,7 +437,7 @@
|
||||
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
||||
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
||||
</details>
|
||||
<div class="two"><div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label class="check"><input type="checkbox" name="separate_hysteresis"> <span data-i18n="zones.separateHysteresis">Set heating and cooling hysteresis separately</span></label></div><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||
<div class="two"><div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label class="check" data-i18n-title="zones.separateHysteresisHint" title="Use different hysteresis values for heating and cooling"><input type="checkbox" name="separate_hysteresis"> <span data-i18n="zones.separateHysteresis">Set heating and cooling hysteresis separately</span></label></div><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||
<div id="separateHysteresisFields" class="two" hidden><label><span data-i18n="zones.coolHysteresis">Cooling hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="cool_hysteresis" value="0.6"></label><label><span data-i18n="zones.heatHysteresis">Heating hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis" value="0.6"></label></div>
|
||||
<div id="externalSensorFields">
|
||||
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
|
||||
+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: {}, zoneTemperatureTimers: {},
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
};
|
||||
|
||||
+27
-3
@@ -208,10 +208,18 @@ function groupState(group) {
|
||||
const zones = groupZones(group);
|
||||
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
|
||||
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
|
||||
const customTargets = zones
|
||||
.filter(zone => (zone.manual_preset || 'auto') === 'custom')
|
||||
.map(zone => Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint))
|
||||
.filter(Number.isFinite);
|
||||
const customTemperature = customTargets.length === zones.length && customTargets.length && customTargets.every(value => Math.abs(value - customTargets[0]) < 0.05)
|
||||
? customTargets[0]
|
||||
: null;
|
||||
return {
|
||||
zones,
|
||||
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
|
||||
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
|
||||
customTemperature,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,12 +231,16 @@ function groupCard(group, detailed = false) {
|
||||
const masterOff = app.settings?.house_power_enabled === 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>
|
||||
${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('')}</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>
|
||||
${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>`;
|
||||
@@ -245,8 +257,16 @@ 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 api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||||
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);
|
||||
@@ -261,7 +281,11 @@ async function sendGroupControl(id, patch) {
|
||||
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) { toast(error.message, true); }
|
||||
} catch (error) {
|
||||
if (app.groupControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
} finally {
|
||||
if (app.groupControlQueue[id] === request) delete app.groupControlQueue[id];
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupZoneChoices(selectedIds = null) {
|
||||
|
||||
+20
-2
@@ -116,6 +116,17 @@ document.addEventListener('click', async event => {
|
||||
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-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(); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-temperature') {
|
||||
const input=button.closest('.group-card')?.querySelector('[data-group-custom-temperature]');
|
||||
const value=parseDecimal(input?.value);
|
||||
if(!Number.isFinite(value) || value<8 || value>30) return toast(tr('groups.customTemperatureRange'),true);
|
||||
return sendGroupControl(button.dataset.id,{preset:'custom',setpoint:Math.round(value*10)/10});
|
||||
}
|
||||
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; }
|
||||
@@ -183,6 +194,13 @@ $('#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('keydown', event => {
|
||||
const input=event.target.closest?.('[data-group-custom-temperature]');
|
||||
if(!input || event.key!=='Enter') return;
|
||||
event.preventDefault();
|
||||
input.closest('.group-card')?.querySelector('[data-action="group-custom-temperature"]')?.click();
|
||||
});
|
||||
|
||||
$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true}));
|
||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
|
||||
@@ -317,7 +335,7 @@ $('#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 zone = await enqueueZoneControlRequest(id, {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); }
|
||||
@@ -356,7 +374,7 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
||||
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
|
||||
}
|
||||
await runFormTask(form, async () => {
|
||||
const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}});
|
||||
const updatedZone = await enqueueZoneControlRequest(raw.zone_id, {temporary_quick_thermostat:request});
|
||||
const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone;
|
||||
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
|
||||
}, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'});
|
||||
|
||||
+22
-3
@@ -46,20 +46,37 @@ function updateDevice(device) {
|
||||
if (index >= 0) app.devices[index] = device; else app.devices.push(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})
|
||||
);
|
||||
app.zoneControlQueue[id] = request;
|
||||
request.finally(() => {
|
||||
if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id];
|
||||
}).catch(() => {});
|
||||
return request;
|
||||
}
|
||||
|
||||
async function sendZoneLocalPower(id, power) {
|
||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||
app.zoneControlSeq[id] = sequence;
|
||||
try {
|
||||
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{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);
|
||||
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
} catch (error) {
|
||||
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendZoneControl(id, patch) {
|
||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||
app.zoneControlSeq[id] = sequence;
|
||||
try {
|
||||
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||||
const zone = await enqueueZoneControlRequest(id, patch);
|
||||
if (app.zoneControlSeq[id] !== sequence) return;
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
@@ -69,6 +86,7 @@ async function sendZoneControl(id, patch) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
@@ -93,6 +111,7 @@ function beginInlineTemperatureEdit(target) {
|
||||
input.inputMode = 'decimal';
|
||||
input.value = value.toFixed(decimals);
|
||||
input.setAttribute('aria-label', tr('common.targetTemperature'));
|
||||
input.title = tr('common.targetTemperature');
|
||||
target.classList.add('editing');
|
||||
target.replaceChildren(input);
|
||||
input.focus();
|
||||
|
||||
+8
-1
@@ -710,6 +710,13 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
|
||||
.group-button-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
|
||||
.group-button-row button,.group-mode-row button,.group-preset-row button { min-height:36px; }
|
||||
.group-button-row button.active { color:var(--accent-text); background:var(--accent); font-weight:800; }
|
||||
.group-custom-temperature { padding:8px 9px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); }
|
||||
.group-custom-temperature label > span { display:block; margin-bottom:5px; color:var(--muted); font-size:10px; }
|
||||
.group-custom-temperature-row { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:6px; }
|
||||
.group-custom-temperature-row input { min-width:0; height:36px; text-align:center; font-weight:700; }
|
||||
.group-custom-temperature-row > span { color:var(--muted); font-size:12px; }
|
||||
.group-custom-temperature-row button { min-height:36px; white-space:nowrap; }
|
||||
|
||||
.group-zone-choices { display:grid; gap:7px; max-height:300px; overflow:auto; }
|
||||
.group-zone-choice { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:10px; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
|
||||
.group-zone-choice input { width:18px; min-height:18px; accent-color:var(--accent); }
|
||||
@@ -772,7 +779,7 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36
|
||||
.dashboard-group-strip .group-mode-row,
|
||||
.dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; }
|
||||
.dashboard-group-strip .group-mode-row { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(5,minmax(0,1fr)); }
|
||||
.dashboard-group-strip .group-button-row button,
|
||||
.dashboard-group-strip .group-mode-row button,
|
||||
.dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; }
|
||||
|
||||
Reference in New Issue
Block a user