diff --git a/Cargo.lock b/Cargo.lock index 8b50698..4379953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.16" +version = "0.8.17" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index d64679f..81956ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.16" +version = "0.8.17" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 237475a..96feb5c 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -5,7 +5,7 @@ d3ee62701b27064418df4f62f43ef4a00528275e8338c2f48b4ea2010e11c10d ./Cargo.lock 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 4a40bef9464c7da8e46c3a7396b417bafee37fea8e0d89406d025eb4e8e6c47e ./README.md ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs -b99221eeadfd0f42122d570b0b402f698f97622ebafea5c97045fa7a2151dbd0 ./docs/API.md +bbac52c61e28b554027902f97044ab5039acf3f58aeca1d5fd204013ffcc0cbf ./docs/API.md 95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py @@ -25,8 +25,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./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 -1d07d0c32e70574374adaabbfcb59d3bdb4790e80ecdc89397bccf54c25a37e5 ./lang/en.json -9a553a4b4383ed9a349d401f464ec7f2bbf89a082a973e82ba56a49a17907892 ./lang/pl.json +4b1b820432bd96ba16c0c3a9f363cf6d3a6bdd12b58bdbf7b45a0611ad56324b ./lang/en.json +a1eb1b3cab4d9f23c6121259157060ba3375e29c6507bc94095f033170242244 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -56,7 +56,7 @@ eed70af84e9ff9f0ec1b2005836e9ab9aa6a4e73c5c876ec161e5d5c96965b26 ./src/api/sche a4d0d6c6ffb7944d336a2cf749a27ee5ed966fd94ab29464ce641873783ce756 ./src/api/settings.rs 74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs -1f998bdac886fcbe55174ef6a916b9bb1219442b9fc313394eeea763daa8cc85 ./src/api/zones.rs +29b6503aa80dd4ad4e444c4f25ca763adfb60c2de099afbb4fc1131e9d080cfd ./src/api/zones.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs @@ -83,9 +83,9 @@ ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/s e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs b453a8841494fd82d8fa4133dcd47dc59715a6a327375b5e13ffbaef431be700 ./src/engine/temporary_thermostat.rs -acfb8569299dd4ee80ec49a5cbd7e14645580ba61936d4640c11c414626ffd4e ./src/engine/tests.rs +36038bf3f6d5c5ef3a146e99caacfbf20f7636872a8bdfe08866e3037db82f11 ./src/engine/tests.rs c7e07dcf4419a4a64566620ba2006c5532a6d4576c90cb92855bca4c2deddcf6 ./src/engine/zone_actions.rs -6eed87586db85d18b64ab41c359ae9a7196091069f4e98c20b181b42863ba67c ./src/engine/zone_control.rs +9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs 6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs @@ -102,7 +102,7 @@ cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/h 668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs 0e0d47d88bb6b7b446fe9a89a69cf46dfe041f8f279eda2f5d8f91c296e19312 ./src/models/temporary_thermostat.rs -be6ef6a17eb41ca41cb21938af6456c5ee76e3278098303c9bd1202fcb2e01f4 ./src/models/zone.rs +799113b1209cc40c99a463a49fc28aca4d49d10d3e8dc6861bb579f1e3f637d0 ./src/models/zone.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs @@ -127,23 +127,23 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -76bd4dca7584a7b1f7fc636a0f6db66e44e93a65e81b8784bfb3dfad7f1601a4 ./web/index.html +4df1b4c17f178d8edff61394da9195961f850db4d926d30ae44b04c3120e7a12 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js 64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js -9f2642f4b2b61fe29866e4e70b7aa3bd1b29c260d5561b2b517ef0e1ed7ae3e5 ./web/js/core.js -86e3556f45ecf10a812986bae30696cb5d5c7c4de24bd8cb539481be3ce5a8d9 ./web/js/dashboard.js -cd42f4061ec4079fad51385d854545b83dfd981a740536f326d029e42a099d5b ./web/js/entities.js -39cb480586bbd5f8bc24833068467c3a59720a64d2bd7c2aa70f02f27413bb0f ./web/js/events.js -89e55155af2392dcc8b0fe56ebe9b0a0a786e430c952cf9aa7e3008354d12ad2 ./web/js/forms.js +0f743533728e3be60a30cb7955afc8dcefc13b68f967538e3a6ffbe76cfa601c ./web/js/core.js +6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js +04247ee5a716376dcb959f6f9d2005b0b058da7bacca2cd7721bccfe0123b07e ./web/js/entities.js +668b52056d6e083b6adaa72e483cf70eca680144d1f821ca6485b601e340b4aa ./web/js/events.js +2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js 6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js -8f8656de7535c57832905a3bde57a382644acd519646377a35ea3ffa9079f926 ./web/js/navigation.js +963282960efbaf4f52954f828304ed1f452a8b661c500a6fe7c230c0ff3b210a ./web/js/navigation.js d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js 45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js 28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js 9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -3a0d6876bc6862d21b1b62601d6a779c282770eaea0ac51dbe492e0ed39fff8d ./web/styles.css +98fe010f13bec1f408bcd1a0015e4d866ae2a966812d6a5e76ec47fbf8b0bdfd ./web/styles.css 03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 255c953..054d025 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Current release: 0.8.16** +**Current release: 0.8.17** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/docs/API.md b/docs/API.md index f2c66cd..6c51455 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.8.16**. +HTTP and WebSocket API for GREE Controller **0.8.17**. [← Main documentation](../README.md) @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.8.16", + "version": "0.8.17", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -236,7 +236,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.8.16", + "version": "0.8.17", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -438,6 +438,9 @@ The backend sends only properties that differ from the last known device state. "heat_sleep_setpoint": 19.0, "heat_away_setpoint": 17.0, "hysteresis": 0.6, + "separate_hysteresis": false, + "cool_hysteresis": 0.6, + "heat_hysteresis": 0.6, "min_on_seconds": 180, "min_off_seconds": 180, "min_adjust_seconds": 120, @@ -458,7 +461,8 @@ Important rules: - `device_id` must reference an existing device and thermostat ownership must remain valid/safe. - `mode`: `cool` or `heat` when not inheriting house mode. - temperature/profile values are constrained to the supported thermostat range. -- `hysteresis`: controller-valid range is approximately `0.1..5.0` °C. +- `hysteresis`: shared controller hysteresis, valid range `0.1..5.0` °C. +- `separate_hysteresis`: when `true`, cooling uses `cool_hysteresis` and heating uses `heat_hysteresis`; both use the same `0.1..5.0` °C range. - `standby_offset_c`: bounded thermostat offset. - `sensor_source`: `device`, `home_assistant` or `combined`. - `external_sensor_weight`: `0..1`. @@ -488,6 +492,8 @@ Safely powers the owned device off before detaching thermostat ownership. Return ### `POST /api/zones/{id}/control` +Manual `setpoint` values are retained with 0.1 °C precision. The physical GREE unit setpoint is still rounded to the whole-degree resolution supported by the protocol. + Quick thermostat endpoint. Body fields are optional and can be combined: ```json diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 455b578..11d0b17 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.16", + "version": "0.8.17", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 2b7c5ab..e775f53 100644 --- a/lang/en.json +++ b/lang/en.json @@ -64,6 +64,8 @@ "devices.simulator": "simulator", "devices.power": "Power", "devices.currentTemperature": "Temperature", + "common.clickTemperatureToEdit": "Click the temperature to type a value", + "common.targetTemperature": "Target temperature", "devices.outdoor": "outside", "devices.quiet": "Quiet", "devices.swing": "Swing", @@ -78,6 +80,9 @@ "zones.new": "New zone", "zones.description": "Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature, preset and mode changes.", "zones.hysteresis": "Hysteresis °C", + "zones.separateHysteresis": "Set heating and cooling separately", + "zones.coolHysteresis": "Cooling hysteresis °C", + "zones.heatHysteresis": "Heating hysteresis °C", "zones.source": "Temperature strategy", "zones.greeSensor": "GREE only", "zones.combinedSensor": "GREE + room sensor", diff --git a/lang/pl.json b/lang/pl.json index d1e64e2..0f7cdb9 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -64,6 +64,8 @@ "devices.simulator": "symulator", "devices.power": "Zasilanie", "devices.currentTemperature": "Temperatura", + "common.clickTemperatureToEdit": "Kliknij temperaturę, aby wpisać wartość", + "common.targetTemperature": "Temperatura docelowa", "devices.outdoor": "na zewnątrz", "devices.quiet": "Cichy", "devices.swing": "Swing", @@ -78,6 +80,9 @@ "zones.new": "Nowa strefa", "zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Szybkich termostatach” na Pulpicie.", "zones.hysteresis": "Histereza °C", + "zones.separateHysteresis": "Ustaw osobno dla grzania i chłodzenia", + "zones.coolHysteresis": "Histereza chłodzenia °C", + "zones.heatHysteresis": "Histereza grzania °C", "zones.source": "Strategia temperatury", "zones.greeSensor": "Tylko GREE", "zones.combinedSensor": "GREE + czujnik pokojowy", diff --git a/src/api/zones.rs b/src/api/zones.rs index 8779746..9262abd 100644 --- a/src/api/zones.rs +++ b/src/api/zones.rs @@ -24,6 +24,12 @@ struct ZoneInput { heat_away_setpoint: f64, #[serde(default = "hysteresis")] hysteresis: f64, + #[serde(default)] + separate_hysteresis: bool, + #[serde(default = "hysteresis")] + cool_hysteresis: f64, + #[serde(default = "hysteresis")] + heat_hysteresis: f64, #[serde(default = "cycle")] min_on_seconds: u64, #[serde(default = "cycle")] @@ -73,6 +79,8 @@ impl ZoneInput { if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); } } if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); } + if !(0.1..=5.0).contains(&self.cool_hysteresis) { return Err(AppError::BadRequest("cooling hysteresis must be between 0.1 and 5 C".into())); } + if !(0.1..=5.0).contains(&self.heat_hysteresis) { return Err(AppError::BadRequest("heating hysteresis must be between 0.1 and 5 C".into())); } if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); } if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); } @@ -90,7 +98,9 @@ impl ZoneInput { cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint, cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint, heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint, - hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, + hysteresis: self.hysteresis, separate_hysteresis: self.separate_hysteresis, + cool_hysteresis: self.cool_hysteresis, heat_hysteresis: self.heat_hysteresis, + min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan, sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds, @@ -312,7 +322,8 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); } let target = (target * 2.0).round() / 2.0; - let min_stable_tolerance = (zone.hysteresis.max(0.1) / 2.0 + 0.1).min(3.0); + let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() }; + let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0); let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); if !(0.1..=3.0).contains(&requested_tolerance) { return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); @@ -488,7 +499,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } if let Some(value) = patch.setpoint { if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } - let value = (value * 2.0).round() / 2.0; + let value = (value * 10.0).round() / 10.0; zone.setpoint = value; zone.manual_setpoint = Some(value); zone.effective_setpoint = Some(value); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index eb29848..df69de5 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -107,7 +107,8 @@ mod tests { mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1, cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0, heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0, - hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true, + hysteresis: 0.6, separate_hysteresis: false, cool_hysteresis: 0.6, heat_hysteresis: 0.6, + min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true, sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(), @@ -119,6 +120,20 @@ mod tests { } } + #[test] + fn zone_can_use_separate_heating_and_cooling_hysteresis() { + let mut zone = test_zone("device"); + zone.hysteresis = 0.6; + zone.separate_hysteresis = true; + zone.cool_hysteresis = 0.4; + zone.heat_hysteresis = 1.2; + assert_eq!(zone.hysteresis_for_mode("cool"), 0.4); + assert_eq!(zone.hysteresis_for_mode("heat"), 1.2); + zone.separate_hysteresis = false; + assert_eq!(zone.hysteresis_for_mode("cool"), 0.6); + assert_eq!(zone.hysteresis_for_mode("heat"), 0.6); + } + #[test] fn external_device_change_detects_manual_climate_controls() { let zone = test_zone("device"); diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index e5340f1..4757d51 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -336,7 +336,7 @@ async fn control_zones(state: &AppState) -> Result<()> { continue; }; - let half = zone.hysteresis.max(0.1) / 2.0; + let half = zone.hysteresis_for_mode(effective_mode) / 2.0; let previous_demand = zone.demand; zone.demand = match effective_mode { "heat" => { diff --git a/src/models/zone.rs b/src/models/zone.rs index dc0bd0b..fc628d2 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -30,6 +30,13 @@ pub struct Zone { pub heat_away_setpoint: f64, #[serde(default = "default_hysteresis")] pub hysteresis: f64, + /// When enabled, heating and cooling use independent hysteresis values. + #[serde(default)] + pub separate_hysteresis: bool, + #[serde(default = "default_hysteresis")] + pub cool_hysteresis: f64, + #[serde(default = "default_hysteresis")] + pub heat_hysteresis: f64, #[serde(default = "default_min_cycle")] pub min_on_seconds: u64, #[serde(default = "default_min_cycle")] @@ -153,6 +160,17 @@ pub struct Zone { pub updated_at: DateTime, } +impl Zone { + pub fn hysteresis_for_mode(&self, mode: &str) -> f64 { + let value = if self.separate_hysteresis { + if mode == "heat" { self.heat_hysteresis } else { self.cool_hysteresis } + } else { + self.hysteresis + }; + value.max(0.1) + } +} + fn default_sensor_source() -> String { "device".into() } diff --git a/web/index.html b/web/index.html index aa5e4ba..bfa3ba3 100644 --- a/web/index.html +++ b/web/index.html @@ -437,7 +437,8 @@
Cooling
Heating
-
+
+
diff --git a/web/js/core.js b/web/js/core.js index c090e0d..0da7dcf 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -47,6 +47,12 @@ const tr = (key, params = {}) => { return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`); }; const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; +const zoneHysteresisForMode = (zone, mode) => { + const common = Number(zone?.hysteresis ?? 0.6); + if (!zone?.separate_hysteresis) return Number.isFinite(common) ? common : 0.6; + const value = Number(mode === 'heat' ? zone?.heat_hysteresis : zone?.cool_hysteresis); + return Number.isFinite(value) ? value : (Number.isFinite(common) ? common : 0.6); +}; const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value); const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode); diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 20ae239..2626f98 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -175,7 +175,7 @@ function buildZoneSimulation(zone, planZone) { const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature); const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint); const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature); - const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1); + const hysteresis = Math.max(simulationNumeric(zoneHysteresisForMode(zone, mode)) || 0.4, 0.1); const half = hysteresis / 2; let status = 'waiting'; let demand = false; diff --git a/web/js/entities.js b/web/js/entities.js index e6ff8aa..ca87d73 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -32,7 +32,7 @@ function deviceCard(device, detailed = false) {
-
${Number(device.target_temperature).toFixed(1)}°C
+
${Number(device.target_temperature).toFixed(1)}°C
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
@@ -168,7 +168,7 @@ function zoneCard(zone, detailed = true) {
${esc(tr('zones.modePolicy'))}${esc(policy)}
${esc(tr('zones.source'))}${esc(zoneStrategyLabel(zone))}
-
${esc(tr('zones.hysteresis'))}${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C
+
${esc(tr('zones.hysteresis'))}${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`}
${esc(tr('zones.smartFan'))}${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}
${esc(sensorDetails)}
@@ -179,7 +179,7 @@ function zoneCard(zone, detailed = true) { return `

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}

${localThermostatPowerControl}${temporaryThermostatControl}
-
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
+
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
${['auto','comfort','sleep','away'].map(preset=>``).join('')}
diff --git a/web/js/events.js b/web/js/events.js index 7f92ca4..4a7e9cd 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -1,5 +1,16 @@ +document.addEventListener('keydown', event => { + const target = event.target.closest?.('[data-temperature-kind]'); + if (!target || event.target.matches('input')) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + beginInlineTemperatureEdit(target); + } +}); + document.addEventListener('click', async event => { + const inlineTarget = event.target.closest('[data-temperature-kind]'); + if (inlineTarget && !event.target.closest('button')) { beginInlineTemperatureEdit(inlineTarget); return; } const button = event.target.closest('button'); if (!button) return; if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; } if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; } @@ -13,8 +24,9 @@ document.addEventListener('click', async event => { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') { - if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; } + if (form) { form.dataset.editingZoneId = ''; form.dataset.separateHysteresisInitialized = 'false'; if (form.elements.id) form.elements.id.value = ''; } updateZoneSensorFields(); + updateZoneHysteresisFields(); } if (button.dataset.open === 'groupDialog') { if (form?.elements.id) form.elements.id.value = ''; @@ -171,6 +183,7 @@ $('#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); +$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true})); $('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField); $('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields); $('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); }); @@ -198,7 +211,7 @@ $('#copyZoneSettings')?.addEventListener('click',()=>{ ha_entity_id:f.elements.ha_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','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','external_sensor_weight_percent','max_sensor_difference','sensor_stale_after_seconds']; copyFields.forEach(name=>{ const input=f.elements[name]; if(!input)return; @@ -206,6 +219,8 @@ $('#copyZoneSettings')?.addEventListener('click',()=>{ if(value!==undefined&&value!==null)input.value=String(value); }); f.elements.smart_fan.checked=source.smart_fan!==false; + f.elements.separate_hysteresis.checked=source.separate_hysteresis===true; + updateZoneHysteresisFields(); if(f.elements.mode_policy) f.elements.mode_policy.value=source.inherit_house_mode===false?(source.mode||'cool'):'house'; f.elements.id.value=protectedValues.id; @@ -278,7 +293,7 @@ $('#zoneForm').addEventListener('submit', async event => { mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint), 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),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds), + 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, sensor_source:raw.sensor_source,ha_entity_id:raw.ha_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 }; diff --git a/web/js/forms.js b/web/js/forms.js index d3251f2..18f4727 100644 --- a/web/js/forms.js +++ b/web/js/forms.js @@ -133,11 +133,15 @@ function validateForm(form) { showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required')); valid = false; } - [ + 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], - ['hysteresis',0.1,5],['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8] - ].forEach(([name,min,max]) => { if (!validateDecimalField(form,name,min,max)) valid = false; }); + ['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8], + ...(form.elements.separate_hysteresis?.checked + ? [['cool_hysteresis',0.1,5],['heat_hysteresis',0.1,5]] + : [['hysteresis',0.1,5]]) + ]; + zoneDecimalFields.forEach(([name,min,max]) => { if (!validateDecimalField(form,name,min,max)) valid = false; }); } if (!valid) { diff --git a/web/js/navigation.js b/web/js/navigation.js index 755c940..835259a 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -69,8 +69,9 @@ async function sendZoneControl(id, patch) { } } -function queueZoneTemperature(zone, value) { - const next = Math.round(clamp(value, 8, 30) * 2) / 2; +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; zone.manual_setpoint = next; zone.setpoint = next; zone.effective_setpoint = next; @@ -79,6 +80,49 @@ function queueZoneTemperature(zone, value) { app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160); } +function beginInlineTemperatureEdit(target) { + if (!target || target.querySelector('input') || target.dataset.editable === 'false') return; + const kind = target.dataset.temperatureKind; + const id = target.dataset.id; + const value = parseDecimal(target.dataset.value); + if (!Number.isFinite(value) || !kind || !id) return; + const decimals = kind === 'zone' ? 1 : 0; + const input = document.createElement('input'); + input.className = 'inline-temperature-input'; + input.type = 'text'; + input.inputMode = 'decimal'; + input.value = value.toFixed(decimals); + input.setAttribute('aria-label', tr('common.targetTemperature')); + target.classList.add('editing'); + target.replaceChildren(input); + input.focus(); + input.select(); + + let finished = false; + const finish = async commit => { + if (finished) return; + finished = true; + const parsed = parseDecimal(input.value); + if (!commit || !Number.isFinite(parsed) || parsed < 8 || parsed > 30) { + if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', {min:8, max:30}), true); + renderAll(); + return; + } + if (kind === 'zone') { + const zone = app.zones.find(item => item.id === id); + if (zone) queueZoneTemperature(zone, parsed, {snapToHalf:false}); + return; + } + const device = app.devices.find(item => item.id === id); + if (device) await sendDeviceCommand(id, {target_temperature:Math.round(clamp(parsed, 8, 30))}); + }; + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { event.preventDefault(); input.blur(); } + if (event.key === 'Escape') { event.preventDefault(); finished = true; renderAll(); } + }); + input.addEventListener('blur', () => finish(true), {once:true}); +} + function showDiscoveryNames(ids) { const wanted = new Set(Array.isArray(ids) ? ids : []); const devices = app.devices.filter(device => wanted.has(device.id)); @@ -115,6 +159,22 @@ function openDialog(id) { } } +function updateZoneHysteresisFields({syncFromCommon=false}={}) { + const form = $('#zoneForm'); + if (!form) return; + const separate = !!form.separate_hysteresis?.checked; + $('#commonHysteresisField').hidden = separate; + $('#separateHysteresisFields').hidden = !separate; + if (separate && syncFromCommon && form.dataset.separateHysteresisInitialized !== 'true') { + const common = parseDecimal(form.hysteresis.value); + if (Number.isFinite(common)) { + form.cool_hysteresis.value = String(common); + form.heat_hysteresis.value = String(common); + } + form.dataset.separateHysteresisInitialized = 'true'; + } +} + function updateZoneSensorFields() { const form = $('#zoneForm'); if (!form) return; @@ -167,7 +227,12 @@ function populateZone(id) { form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120); form.standby_offset_c.value = Number(item.standby_offset_c ?? 2); form.smart_fan.checked = item.smart_fan !== false; - form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog'); + form.separate_hysteresis.checked = item.separate_hysteresis === true; + const commonHysteresis = Number(item.hysteresis ?? 0.6); + form.cool_hysteresis.value = Number(item.separate_hysteresis ? (item.cool_hysteresis ?? commonHysteresis) : commonHysteresis); + form.heat_hysteresis.value = Number(item.separate_hysteresis ? (item.heat_hysteresis ?? commonHysteresis) : commonHysteresis); + form.dataset.separateHysteresisInitialized = item.separate_hysteresis === true ? 'true' : 'false'; + form.enabled.checked = item.enabled; updateZoneSensorFields(); updateZoneHysteresisFields(); openDialog('zoneDialog'); } const dateTimeLocalValue = value => { diff --git a/web/styles.css b/web/styles.css index bd04f6b..d57d440 100644 --- a/web/styles.css +++ b/web/styles.css @@ -141,6 +141,13 @@ h3 { margin-bottom: 10px; } .temperature-control button { width: 42px; height: 42px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; font-size: 22px; } .target-temp { min-width: 106px; text-align: center; font-size: 48px; font-weight: 300; letter-spacing: -.06em; } .target-temp small { margin-left: 3px; color: var(--muted); font-size: 15px; font-weight: 650; } +.editable-target[data-editable="true"], .editable-target:not([data-editable]) { cursor: text; border-radius: 10px; transition: background .15s ease, box-shadow .15s ease; } +.editable-target[data-editable="true"]:hover, .editable-target[data-editable="true"]:focus-visible, .editable-target:not([data-editable]):hover, .editable-target:not([data-editable]):focus-visible { background: var(--surface-2); box-shadow: 0 0 0 1px var(--line); outline: none; } +.editable-target.editing { min-width: 92px; } +.inline-temperature-input { width: 92px; max-width: 100%; padding: 3px 6px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text); font: inherit; font-size: .72em; font-weight: 500; text-align: center; letter-spacing: 0; outline: none; } +.inline-temperature-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); } +.hysteresis-config { display: grid; gap: 10px; } +.hysteresis-config .check { margin-top: 0; } .current-line { display: flex; justify-content: center; gap: 6px; margin: -10px 0 18px; color: var(--muted); font-size: 12px; } .current-line strong { color: var(--text); } .mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; }