diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index a539e9e..33af319 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -25,8 +25,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -fdae654fbb89505a65cc3e05c76907a3ded31dbcae019d4ccf1b1cc146990d71 ./lang/en.json -093022ed2886317c092c19a556ab8cfed86d0322d88f620dfbae515ac64e5ac0 ./lang/pl.json +a3e5842856e956044aefe840eb14fa36f98ae31d5613a7ac9f546df96c6c2366 ./lang/en.json +1baff114849223295ecf91251f525ec7e88f8261adcfa0462fa69ec01fe54517 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -73,14 +73,14 @@ a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_ 77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs e16c1a65057f6fbf11bb6f27493ed975f5c89309b31dec0fa3248b9237494ed3 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs -511caa52f9d1a6c13c15731e6a394ca2790ba642845367da1f0b767c12b5be98 ./src/engine/groups.rs +d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs 4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs 3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs -bcfebf99a03ee676fd9c1c5d395adf271898fcb0df0d39bce3a3b7067456522a ./src/engine/targets.rs +7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs 5631fd8420c4137f4586d98939d24a83701400ac07a3f2fe9da8b8d486df3e64 ./src/engine/tests.rs @@ -131,9 +131,9 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon. 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js 64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js -cf8d97770985b8cf920382725fa33d5f61497a639544bfc528af72b276de0b6a ./web/js/core.js +f3d2a8725f891266c725e3c67557de1f7305db00454ed4147a5c26a32cda31ff ./web/js/core.js 9767d5f2bd2844245245c0bd86dddd528975c1d1dd1d05b1449feda7a95214c4 ./web/js/dashboard.js -c378f6234dbd4310a25ef6d65c12521726cd3fcce1c7069cd878b1d5af491011 ./web/js/entities.js +d0233628681160eb0c3d559887e48f0799b6ad0130bc943d0bea3e409ac05bf9 ./web/js/entities.js 69a052771dfafbd4b778cbc9d1b7a7695427621ed66047282088a3a07e6e4c2e ./web/js/events.js 2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js diff --git a/lang/en.json b/lang/en.json index 36284d2..aca2481 100644 --- a/lang/en.json +++ b/lang/en.json @@ -614,7 +614,8 @@ "zones.turnThermostatOff": "Turn thermostat off", "zones.waiting": "Waiting", "zones.noMeasurement": "No measurement", - "zones.localThermostatOffDescriptionTimed": "This zone is locally off. In {time}, automation will resume and start only if an explicit control source is active.", + "zones.localThermostatOffDescriptionTimed": "This zone is locally off. Automation will resume automatically in {time}. After resuming, the zone stays OFF unless a schedule or another explicit control source is active.", + "zones.groupOffDescriptionTimed": "Group “{group}” is off. Automation will resume automatically in {time}. After resuming, the zone stays OFF unless a schedule or another explicit control source is active.", "zones.localThermostatResuming": "Timer elapsed — automation is checking for an active control source…", "zones.resumeNow": "Resume now", "zones.controlOwner": "Control", diff --git a/lang/pl.json b/lang/pl.json index 9fd7c9b..ff9da29 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -614,7 +614,8 @@ "zones.turnThermostatOff": "Wyłącz termostat", "zones.waiting": "Oczekiwanie", "zones.noMeasurement": "Brak pomiaru", - "zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Za {time} automatyka zostanie wznowiona i uruchomi strefę tylko przy aktywnym źródle sterowania.", + "zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Automatyka wznowi się automatycznie za {time}. Po wznowieniu strefa pozostanie OFF, jeśli nie będzie aktywnego harmonogramu ani innego jawnego sterowania.", + "zones.groupOffDescriptionTimed": "Grupa „{group}” jest wyłączona. Automatyka wznowi się automatycznie za {time}. Po wznowieniu strefa pozostanie OFF, jeśli nie będzie aktywnego harmonogramu ani innego jawnego sterowania.", "zones.localThermostatResuming": "Czas minął — automatyka sprawdza aktywne źródło sterowania…", "zones.resumeNow": "Wznów teraz", "zones.controlOwner": "Sterowanie", diff --git a/src/engine/groups.rs b/src/engine/groups.rs index 62ecd33..3245d6e 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -71,6 +71,14 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl // "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration // take ownership again. The whole-house master remains independent. let manual_group_control = source != "automation.group"; + // A manual Group OFF is a temporary bulk pause. All members receive the same + // 15-minute hand-back deadline, so their thermostat automation resumes together. + // Automation-driven group OFF remains persistent until another automation/user action. + let group_handback_at = if manual_group_control && patch.power == Some(false) { + Some(group.updated_at.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) + } else { + None + }; let explicit_group_power = patch.power.is_some() && manual_group_control; // Applying a manual mode/profile/custom target to an already-enabled group is itself an // explicit scoped takeover. It must wake members that were left locally OFF by a previous @@ -160,10 +168,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl }; } } - // Power OFF is persisted as an ordinary per-zone thermostat OFF, with no automatic - // 15-minute hand-back. This keeps the physical unit off while removing group ownership - // and, importantly, still lets the user turn that thermostat back on independently. - // Power ON clears this scoped OFF and returns the member to group arbitration. + // Group power OFF is represented on every member as a local thermostat OFF so the + // physical unit is stopped immediately and group ownership is released. A manual Group + // OFF gets one synchronized 15-minute hand-back deadline for all members; automation + // Group OFF is persistent. Power ON clears the scoped OFF immediately. let mut force_power_off_after_save = false; let mut applied_group_power: Option = None; let requested_member_power = patch.power.or(manual_group_activation.then_some(true)); @@ -184,7 +192,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl applied_group_power = Some(true); } else { zone.local_thermostat_power = Some(false); - zone.local_thermostat_resume_at = None; + zone.local_thermostat_resume_at = group_handback_at.clone(); zone.local_thermostat_restore_zone_enabled = None; zone.demand = false; zone.demand_since = None; @@ -200,8 +208,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl zone.control_owner = "local_thermostat".into(); zone.control_source = "local_thermostat".into(); zone.control_since = Some(Utc::now()); - zone.control_resume_at = None; - zone.control_reason = format!("Group {} powered the thermostat off; group ownership released", group.name); + zone.control_resume_at = group_handback_at.clone(); + zone.control_reason = if group_handback_at.is_some() { + format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES) + } else { + format!("Group {} powered the thermostat off; group ownership released", group.name) + }; } else if group.power_enabled && zone.local_thermostat_power.is_none() { zone.control_owner = "automation".into(); zone.control_source = format!("group:{}", group.id); diff --git a/web/js/core.js b/web/js/core.js index a9a5565..19f8a0f 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -82,8 +82,9 @@ const formatExtendedCountdown = seconds => { function updateLocalThermostatCountdowns() { $$('[data-local-resume-countdown]').forEach(node => { const remaining = localResumeSeconds(node.dataset.localResumeCountdown); + const group = node.dataset.localResumeGroup || ''; node.textContent = remaining !== null && remaining > 0 - ? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)}) + ? tr(group ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', {group, time:formatCountdown(remaining)}) : tr('zones.localThermostatResuming'); }); } diff --git a/web/js/entities.js b/web/js/entities.js index bfa8c43..55f1e7d 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -241,7 +241,9 @@ function zoneCard(zone, detailed = true) { const mode = zone.inherit_house_mode ? 'house' : zone.mode; const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off'; const idleWithoutTarget = effectiveMode === 'off' && zone.effective_setpoint == null && zone.manual_setpoint == null && zone.manual_preset == null; - const target = idleWithoutTarget ? Number.NaN : Number(zone.effective_setpoint ?? zone.manual_setpoint ?? zone.setpoint); + // OFF means "no active demand", not "forget the thermostat setting". Keep the last + // backend-persisted setpoint visible so Group/Global OFF does not blank the temperature UI. + const target = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); const displayPreset = idleWithoutTarget ? 'auto' : (zone.manual_preset || zone.active_preset || 'comfort'); const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode); const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`; @@ -257,6 +259,9 @@ function zoneCard(zone, detailed = true) { const workingDemand = zone.demand && !!device?.power && !deviceUnavailable && !waitingDemand; const localThermostatPower = zone.local_thermostat_power; const localThermostatResumeAt = zone.local_thermostat_resume_at || null; + const pausedGroup = localThermostatPower === false && localThermostatResumeAt + ? (app.groups || []).find(group => group.power_enabled === false && (group.zone_ids || []).includes(zone.id)) || null + : null; const temporarySession = zone.temporary_quick_thermostat || null; const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null; const localThermostatOverride = localThermostatPower === true || localThermostatPower === false; @@ -275,7 +280,7 @@ function zoneCard(zone, detailed = true) { ? `
${esc(tr('zones.manualDeviceControl'))}

${esc(tr('zones.manualDeviceDescription'))}

` : localThermostatOverride && !temporarySession ? `
${esc(tr('zones.localThermostatControl'))}${localThermostatPower === false && localThermostatResumeAt - ? `

${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}

` + ? `

${esc(tr(pausedGroup ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', {group:pausedGroup?.name || '', time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}

` : `

${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}

`}
` : ''; const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';