This commit is contained in:
Mateusz Gruszczyński
2026-09-01 09:19:33 +02:00
parent ac5a464d96
commit 1a5c1305dc
20 changed files with 200 additions and 42 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.8.16"
version = "0.8.17"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+15 -15
View File
@@ -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
+1 -1
View File
@@ -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.
+10 -4
View File
@@ -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
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+14 -3
View File
@@ -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);
+16 -1
View File
@@ -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");
+1 -1
View File
@@ -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" => {
+18
View File
@@ -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<Utc>,
}
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() }
+2 -1
View File
@@ -437,7 +437,8 @@
<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"><label><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><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"><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>
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
+6
View File
@@ -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);
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -32,7 +32,7 @@ function deviceCard(device, detailed = false) {
</div>
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}></button>
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="${zoneLocked ? 'false' : 'true'}" tabindex="${zoneLocked ? '-1' : '0'}" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
@@ -168,7 +168,7 @@ function zoneCard(zone, detailed = true) {
<div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
<div><small>${esc(tr('zones.source'))}</small><strong>${esc(zoneStrategyLabel(zone))}</strong></div>
<div><small>${esc(tr('zones.hysteresis'))}</small><strong>${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C</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>
<div class="sensor-detail">${esc(sensorDetails)}</div>
@@ -179,7 +179,7 @@ function zoneCard(zone, detailed = true) {
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)}">
<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">${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="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>
+18 -3
View File
@@ -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
};
+7 -3
View File
@@ -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) {
+68 -3
View File
@@ -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 => {
+7
View File
@@ -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; }