This commit is contained in:
Mateusz Gruszczyński committed 2026-10-17 20:00:28 +02:00
1 parent ccf85a2fb8
commit f376f0a7b6
32 files changed
+312 -114

No files matched your search

+3
View File
@@ -1598,6 +1598,9 @@
type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span
data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="text" inputmode="decimal"
step="0.5" min="0.5" max="8" name="standby_offset_c" value="2"></label></div>
<label><span data-i18n="zones.heatAnticipation">Early heat coasting (°C before target)</span><input
type="text" inputmode="decimal" step="0.1" min="0" max="2.5" name="heat_anticipation_c" value="0"></label>
<p class="field-note" data-i18n="zones.heatAnticipationHint">A positive value lowers the GREE setpoint early without powering the indoor unit off. 0 disables early coasting.</p>
<label class="check"><input type="checkbox" name="smart_fan" checked> <span data-i18n="zones.smartFan">Smart fan
assist</span></label>
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds"
+13 -6
View File
@@ -405,15 +405,20 @@ function buildZoneSimulation(zone, planZone) {
status = demand ? 'demand' : 'satisfied';
}
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
const anticipation = Math.max(simulationNumeric(zone?.heat_anticipation_c) || 0, 0);
const coastEnter = target == null ? null : target - anticipation;
const heatCoasting = mode === 'heat' && anticipation > 0 && current != null && coastEnter != null
&& (zone?.heat_coasting ? current > coastEnter - 0.4 + 1e-6 : current + 1e-6 >= coastEnter);
const deliveringHeat = demand && !heatCoasting;
const assist = current == null || target == null || zone?.control_source === 'automation.flow' ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, deliveringHeat, deliveringHeat ? activeTarget : standbyTarget);
const nightActive = !!app.controlPlan?.night_mode_active;
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
let fanSpeed = simulationNumeric(device?.fan_speed);
if (mode !== 'off') {
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, deliveringHeat) : fanSpeed;
if (nightActive) {
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
@@ -423,7 +428,7 @@ function buildZoneSimulation(zone, planZone) {
? tr('simulation.manual')
: nightActive && app.settings?.night_mode?.force_quiet
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
: (zone?.smart_fan ? (!deliveringHeat ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false
? tr('simulation.reasonDisabled')
@@ -431,10 +436,12 @@ function buildZoneSimulation(zone, planZone) {
? tr('simulation.reasonHouseOff')
: current == null || target == null
? tr('simulation.reasonWaiting')
: demand
: heatCoasting
? tr('simulation.reasonCoasting', { target: target.toFixed(1), standby: standbyTarget.toFixed(1) })
: demand
? tr('simulation.reasonDemand', { mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1) })
: tr('simulation.reasonSatisfied', { target: target.toFixed(1), standby: standbyTarget.toFixed(1) });
return { device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning };
return { device, mode, current, target, demand, heatCoasting, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning };
}
function simulationRuleAction(rule) {
+6
View File
@@ -453,6 +453,7 @@ function zoneControlOwnerLabel(zone) {
}
if (owner === 'local_thermostat') return tr('zones.ownerLocalThermostat');
if (source.startsWith('group:')) return tr('zones.ownerGroup');
if (source === 'automation.flow') return tr('zones.ownerFlow');
return tr('zones.ownerAutomation');
}
@@ -478,6 +479,10 @@ function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) {
if (zone.compressor_cancelled_action && device && (!device.power || (['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode))) return tr('zones.queueCancelledStatus');
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');
if (zone.control_owner === 'automation' && zone.control_source === 'automation.flow') {
if (zone.demand && zone.heat_coasting && device?.power) return tr('zones.flowCoasting');
return zone.demand ? tr('zones.flowHeating') : tr('zones.flowStandby');
}
return zone.demand ? tr('zones.runningDemand') : tr('zones.satisfied');
}
@@ -555,6 +560,7 @@ function zoneCard(zone, detailed = true) {
<div><small>${esc(tr('zones.source'))}</small><strong>${esc(zoneStrategyLabel(zone))}</strong></div>
<div><small>${esc(tr('zones.hysteresis'))}</small><strong>${zone.separate_hysteresis ? `${esc(tr('mode.cool'))} ${Number(zone.cool_hysteresis ?? zone.hysteresis ?? 0.6).toFixed(1)}°C · ${esc(tr('mode.heat'))} ${Number(zone.heat_hysteresis ?? zone.hysteresis ?? 0.6).toFixed(1)}°C` : `${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C`}</strong></div>
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
<div><small>${esc(tr('zones.heatAnticipation'))}</small><strong>${Number(zone.heat_anticipation_c ?? 0).toFixed(1)}°C</strong></div>
</div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
${compressorQueuePanel(zone)}
+3 -2
View File
@@ -340,7 +340,7 @@ $('#copyZoneSettings')?.addEventListener('click', () => {
ha_outdoor_entity_id: f.elements.ha_outdoor_entity_id.value,
enabled: f.elements.enabled.checked,
};
const copyFields = ['setpoint', 'cool_comfort_setpoint', 'cool_sleep_setpoint', 'cool_away_setpoint', 'heat_comfort_setpoint', 'heat_sleep_setpoint', 'heat_away_setpoint', 'hysteresis', 'cool_hysteresis', 'heat_hysteresis', 'min_on_seconds', 'min_off_seconds', 'min_adjust_seconds', 'standby_offset_c', 'external_sensor_weight_percent', 'max_sensor_difference', 'sensor_stale_after_seconds'];
const copyFields = ['setpoint', 'cool_comfort_setpoint', 'cool_sleep_setpoint', 'cool_away_setpoint', 'heat_comfort_setpoint', 'heat_sleep_setpoint', 'heat_away_setpoint', 'hysteresis', 'cool_hysteresis', 'heat_hysteresis', 'min_on_seconds', 'min_off_seconds', 'min_adjust_seconds', 'standby_offset_c', 'heat_anticipation_c', 'external_sensor_weight_percent', 'max_sensor_difference', 'sensor_stale_after_seconds'];
copyFields.forEach(name => {
const input = f.elements[name];
if (!input) return;
@@ -583,7 +583,8 @@ $('#zoneForm').addEventListener('submit', async event => {
cool_comfort_setpoint: parseDecimal(raw.cool_comfort_setpoint), cool_sleep_setpoint: parseDecimal(raw.cool_sleep_setpoint), cool_away_setpoint: parseDecimal(raw.cool_away_setpoint),
heat_comfort_setpoint: parseDecimal(raw.heat_comfort_setpoint), heat_sleep_setpoint: parseDecimal(raw.heat_sleep_setpoint), heat_away_setpoint: parseDecimal(raw.heat_away_setpoint),
hysteresis: parseDecimal(raw.hysteresis), separate_hysteresis: form.separate_hysteresis.checked, cool_hysteresis: parseDecimal(raw.cool_hysteresis), heat_hysteresis: parseDecimal(raw.heat_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: parseDecimal(raw.standby_offset_c), smart_fan: form.smart_fan.checked,
min_adjust_seconds: Number(raw.min_adjust_seconds), standby_offset_c: parseDecimal(raw.standby_offset_c),
heat_anticipation_c: parseDecimal(raw.heat_anticipation_c), smart_fan: form.smart_fan.checked,
sensor_source: raw.sensor_source, ha_entity_id: raw.ha_entity_id || null, ha_outdoor_entity_id: raw.ha_outdoor_entity_id || null, external_sensor_weight: Number(raw.external_sensor_weight_percent) / 100, max_sensor_difference: parseDecimal(raw.max_sensor_difference), sensor_stale_after_seconds: Number(raw.sensor_stale_after_seconds || 300), revision: raw.revision ? Number(raw.revision) : null
};
await runFormTask(form, async () => {
+4 -2
View File
@@ -98,7 +98,7 @@ function flowDefaultConfig(kind) {
if (kind === 'night_mode') return {};
if (kind === 'constant') return { value: true };
if (kind === 'shared_input') return sharedFlowReferenceDefaultConfig(app.flowSharedInputs?.[0]);
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', power: null, swing_vertical: null, swing_horizontal: null, cooldown_seconds: 60 };
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', power: null, heat_anticipation_c: null, standby_offset_c: null, swing_vertical: null, swing_horizontal: null, cooldown_seconds: 60 };
if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 };
if (kind === 'device_feature_action') return { device_id: app.devices[0]?.id || '', feature: 'light', value: true, cooldown_seconds: 60 };
if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 };
@@ -813,7 +813,7 @@ function renderFlowInspector() {
else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`;
else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`;
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.1" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><details class="flow-device-options"><summary>${esc(tr('flow.thermostatDeviceOptions'))}</summary><div class="two">${flowOptionalLouverField(c, 'swing_vertical', 'vertical')}${flowOptionalLouverField(c, 'swing_horizontal', 'horizontal')}</div><p class="field-note">${esc(tr('flow.thermostatDeviceOptionsHint'))}</p></details><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.1" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><div class="two"><label><span>${esc(tr('zones.heatAnticipation'))}</span><input type="number" min="0" max="2.5" step="0.1" data-flow-config="heat_anticipation_c" value="${c.heat_anticipation_c == null ? '' : Number(c.heat_anticipation_c)}" placeholder="—"></label><label><span>${esc(tr('zones.standbyOffset'))}</span><input type="number" min="0.5" max="8" step="0.1" data-flow-config="standby_offset_c" value="${c.standby_offset_c == null ? '' : Number(c.standby_offset_c)}" placeholder="—"></label></div><details class="flow-device-options"><summary>${esc(tr('flow.thermostatDeviceOptions'))}</summary><div class="two">${flowOptionalLouverField(c, 'swing_vertical', 'vertical')}${flowOptionalLouverField(c, 'swing_horizontal', 'horizontal')}</div><p class="field-note">${esc(tr('flow.thermostatDeviceOptionsHint'))}</p></details><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
else if (node.kind === 'device_feature_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.deviceFeature'))}</span><select data-flow-config="feature">${flowDeviceFeatureOptions(c.feature)}</select></label>${flowDeviceFeatureValueField(c)}<p class="field-note">${esc(tr('flow.deviceFeatureHint'))}</p><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
@@ -1736,6 +1736,8 @@ function updateFlowConfig(input) {
else if (wasLouver) node.config.value = '';
}
else if (node.kind === 'device_state' && key === 'value' && ['swing_vertical', 'swing_horizontal'].includes(node.config.field)) node.config.value = Number(input.value);
else if (node.kind === 'zone_thermostat' && ['heat_anticipation_c', 'standby_offset_c'].includes(key))
node.config[key] = input.value === '' ? null : Number(input.value);
else if (['swing_vertical', 'swing_horizontal'].includes(key)) node.config[key] = input.value === '' ? null : Number(input.value);
else if (['power', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true';
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
+1 -1
View File
@@ -150,7 +150,7 @@ function validateForm(form) {
const zoneDecimalFields = [
['cool_comfort_setpoint', 8, 30], ['cool_sleep_setpoint', 8, 30], ['cool_away_setpoint', 8, 30],
['heat_comfort_setpoint', 8, 30], ['heat_sleep_setpoint', 8, 30], ['heat_away_setpoint', 8, 30],
['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8],
['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8], ['heat_anticipation_c', 0, 2.5],
...(form.elements.separate_hysteresis?.checked
? [['cool_hysteresis', 0.1, 5], ['heat_hysteresis', 0.1, 5]]
: [['hysteresis', 0.1, 5]])
+1
View File
@@ -598,6 +598,7 @@ function populateZone(id) {
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.heat_anticipation_c.value = Number(item.heat_anticipation_c ?? 0);
form.smart_fan.checked = item.smart_fan !== false;
form.separate_hysteresis.checked = item.separate_hysteresis === true;
const commonHysteresis = Number(item.hysteresis ?? 0.6);