diff --git a/Cargo.lock b/Cargo.lock index 8aead0e..59aa397 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.16.4" +version = "0.16.5" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 521fa3b..079cc09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.16.4" +version = "0.16.5" 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 802b50e..4dc4afd 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,17 +1,17 @@ de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example 2fd3bf1fb7ef548c7abc9b26d1cc4ef470784b8637af65446b845b27a45b547b ./.gitignore -147eed97b308e0c27d6a5ee738a0ee710fa6ba2c6bbf7503df0fe484e0c7f842 ./Cargo.lock -c604af0060aef4e79d2e11fd1e1222a2444cebc835bc50629cff3d2373c5a05a ./Cargo.toml +795c232a7845a217a5711da4d9547282ba7f42fd160a249df07d631fac83928b ./Cargo.lock +2c57e2334ace9e7e320c97b14e1b0372c881717dad4bf3e6ef6abae252ed8056 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -75a6652611273ae458cb3509401cdb196d1084db1e49496ecdce448f490e6249 ./README.md -eedafc7faaa9eb95b564a249a4fef82d5f4cc32697eb0e370b96264f3c47b69c ./SOURCE_MANIFEST.sha256 +eb8dd4d456419762d78e8d426326ac33fa99dbf857ab0f34f52dadc30e8a62be ./README.md +c0f61039799416d2d72d37be85beabd1a68fc3325563189732027df31e7efc2b ./SOURCE_MANIFEST.sha256 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs -bb9840a0dc2b89d057d1461ae581ee61ebf856e3b6d38bc7d1bdd111eef7c550 ./docs/API.md +e961deaaa82be18f9e2933999dda33f55bfc648200e62043b5e8fffdfdd4464c ./docs/API.md 4f9826f1bf3b42ed2d62d8a8e9221b50175b3a9fdda7e9d219351f7a33039257 ./docs/FLOW.md -52c8dc48aed987fd28b2b543af1e5a782efb2a44104ccb0fd6b1740255cf384e ./docs/openapi.json -d8a1b3e34cf8a79ac3296bf600ad47826d639cdef4c1c2c099f8b6b21a391ebd ./flows/simple-heating/igor-simple-heat-v5.flow.json -bd9a07b8ca4b090ad0089e9037492cc25c47f3456b97ec08152947314ea904a6 ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json -c4e6e3e8df5eb7f9730a79fb90edd155f0510f7bb4862a04b9f35ba649c22376 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json +5c24d9f03e5025eda289e6862a7919fe3951992f7120647dc6e0cd0f1f9aa96f ./docs/openapi.json +a5ad73f7933031dfaa99aa92e27f10223d53fb84b14ee0a378eb5f10eb80e8c8 ./flows/simple-heating/igor-simple-heat-v5.flow.json +9743f4328a18bc9d89d10a9011d13ca201a4f352db2119335a66a2147a9a486d ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json +2d804831a72577e80dc1ec3cf4f86dc8bcf00b1d8c80929896f11da0230b2b07 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md @@ -25,7 +25,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -e8d079ebe63cd2916bd219cd45f583940e3d7ba5769af8deb1038415084a1a35 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +4ecd424fd1969a1bcd81eb85ea5ac50295a0d7691d50013027a7c2e400aec449 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -33,10 +33,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -904b21f54c7af0f59d91016d8513baaea5e927a349e18207f4290c51d7f2be8a ./ha-addon/repository/gree-controller/CHANGELOG.md +a4ae5bfa385365ef5db5332f2635a77e212a82e00a3cf473c5617ad134d3133e ./ha-addon/repository/gree-controller/CHANGELOG.md 50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md e3834bf712d4cd2acc715d8c85db96dd6e2fb3fbb758dac537f6034c337b3745 ./ha-addon/repository/gree-controller/README.md -efe06cb35044b19474ae11e5ef42e3201787ff1d164087cdbdb845a5ad6a16d8 ./ha-addon/repository/gree-controller/config.yaml +1fa36a0a0a3219604073950cd936c68fa6de1f51c031443ab22bf66596e0733f ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -46,8 +46,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -4bf92ee5627be67b66d76a6106b224159f95071f0aa2a727cd964e113bc3b32a ./lang/en.json -1e056cd915ba2e808db1287c99c06bbbbb3c98e5008481471b895e6ec20ea43a ./lang/pl.json +f3c1286c3758a98b6a2945562753262e9083a6c638a1dcae556c3e978b4a69af ./lang/en.json +ac6e6285a6153c5d8ce3c45d81ca6fe6c688505957e142e79e0ec0b496c400f3 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -103,6 +103,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw b1e7a7a7a1d6e21012808818ff1478f5116a3dc4c7da3650b57a719ca9df7873 ./scripts/test_flow_block_help_ui.js e3825cfd9e6e149fd227c521d886dc116170f2553d4d7639d61874267f257b6e ./scripts/test_flow_disable_ui.js 2942b34d82d06c950fbf167442fd1a25185de193055e02f2ce8dff62c2039add ./scripts/test_simple_heating_ui.js +9bef00c3a0956c5c893eff6cf1fee9681140a1e76da630fa5a347bce19f07981 ./scripts/test_temperature_explanation_ui.js b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh c260a6d291ad913f6aca60f23a0acf6928d68cfb0545b2a698d58c5a95fcf934 ./scripts/verify_simple_heating.py c32caf3ac7b521324d1d27233c271374ccf4efc3d243544ece727372c792e14a ./src/api.rs @@ -127,7 +128,7 @@ b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/publ fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs 7c974a98c72092886247f67c3a1c62736898d4b279ceb9c4c306690a3c6e4294 ./src/api/system.rs 3b0124a76dbc654584bcf186d43f89d20b6e7fd980ce0ffbf5a9e8eced5ec7f6 ./src/api/websocket.rs -eaa4070afbc19686d5e9bdb2d24fb2c7fc4bf1f90dc1c4f12fef17017d4cb975 ./src/api/zones.rs +79076544f4145c0eb120d544e9fab767b5c7ae625c2db007ee8be8a06bb0b85e ./src/api/zones.rs 32fa38d4315682cd3ef3ecdc91e571c2729b5e68b6f2bff965e43de3fdd2aeed ./src/config.rs de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs 87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs @@ -146,7 +147,7 @@ f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_ e6d6bf39e254bd2779d3bcb31d966aa3f6688ebfa91b4088b70fd796cad66596 ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs -62a15ea757c53796149e670863dae1b4e1a16ceb6ef88ed84ebd3089c0b9880d ./src/engine/control_plan.rs +c6b9651090e89fe0436826f66ae56825bf1e4b209ef8a27beb22fb1deaaa8c36 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 519f67ab805f61afea107215f5df50562fa1669a48334cc015570952e25e773a ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs @@ -158,13 +159,13 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r 48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs 9adfcb3c3dffbaa870b314904bd1995e7b78c3f12febd69e23433179e4bf3fba ./src/engine/simple_heat.rs 2694363e607b7f4407ed07c24019c457f0e0d093cb336ee2dc5cf77487c528f6 ./src/engine/targets.rs -d62ac5e97a36bbdb31bc23871ea7de73d8ac64a1e8203153d9faff0b1a075bc9 ./src/engine/temperature.rs +b35d7e96e50d40bf26be2e47e1fa1d393f4c07caeac41f3595f5d595579e55f7 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -4160811f47eca6e75970fe6c6b95198f86a4b97b7050531c19a19efb3d76d97d ./src/engine/tests.rs -07c3c462884344f018654267de3d454d895343b4699e34635e4d1ec88546df0a ./src/engine/zone_actions.rs -e246b6d6e3126941b989735a27e5fbe88dcaf351420aaaa060bb3761620d8244 ./src/engine/zone_control.rs +f563f2da2409bf291d67daa1b2b2fd6c7a463a3a11343afb08131d20cd26cd69 ./src/engine/tests.rs +63ded0ec62833699cbb7fcc6e4189ef19feafc23b9838d8fe9ba98889feb3e67 ./src/engine/zone_actions.rs +38256ec0e5ee735aa2f7ad7f8daaf2aa96077ea16a6c2cbceac43c4fe2bf8aa5 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs -d681f2aa53c39f37411834c300e9200299f94ee4ea021f44f70a3ea1774bd698 ./src/home_assistant.rs +8e181f29119ef6c32ee3821fe220ade97298c105c681c41466af0c16e539b7db ./src/home_assistant.rs bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs 2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs @@ -181,7 +182,7 @@ aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/f 17cac849056b677a3872e600a0d99218fb383ad5ec91cde596562df8e2f47508 ./src/models/runtime.rs 41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs -79cb16a9f12e8a20fcf0acc8649d771b8a76a05503c0372443d29003536c1bcb ./src/models/zone.rs +2b861f3b37274f4d0b9713706ae959220935bdaf63afc513329d1c48ac0f4dd9 ./src/models/zone.rs 4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs 7746bca683809b82529ed152272ae5c0cd3971758a02e076def58f770e7adf10 ./src/protocol/crypto.rs 578b9f39110abbeb98323044b6ae1a941733c82021afeb4d7ff78ad4256b5aa5 ./src/protocol/gree.rs @@ -207,16 +208,16 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -b030e61b30435495f492e15fa9daf228968cc1de2982e8f40a626452dbf5b81a ./web/css/styles.css +49d7992d8ebaf0b1eddd6538479b00a750897bc0454f3daa37eb01ed914bea8b ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -bbe640e99447f632383b2eb4b52da580ab95d60cd45cda47f6fae1c2380e5b8a ./web/index.html +bb1229db66ab2b510a44308e21796ba01f54c36238f5785ff7fc998dedd09c18 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js 07629b331bd498e8bea769d577321bbd12d9b076264242706d4ef16165fb22fa ./web/js-dynamic/core.js -ef20fb1e706105d0afdc2fa505d92ea52e43d93c9a687f2a09d381227a464564 ./web/js-dynamic/dashboard.js -3a6d2edd64fbdc2a8d468b05816366a9e05df284adea752c42a91e2a3a7bd786 ./web/js-dynamic/entities.js +1bf5b5e295f685b4c54064eb91220a50cb85264f7e90dfcd5bfe5c5e8ac111a2 ./web/js-dynamic/dashboard.js +4ddfe365f66b968fae490b19ab321e3497f9fba4e220123edd0a4e0353254d97 ./web/js-dynamic/entities.js bc1e7c4cccda0447fe805c61e655b773375bf503141195b82d4e95cdf093ec80 ./web/js-dynamic/events.js 3e2a5f1aab56e918624e6e7af064eecb9e94e554fd4d185715f8ecf3747a6895 ./web/js-dynamic/flows.js 1d54ae8919edb61414f9ffc46f85394b29c3c355827120264352cccb32f65e68 ./web/js-dynamic/forms.js diff --git a/README.md b/README.md index b9157b4..32ea073 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ 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. -**Version: 0.16.4** +**Version: 0.16.5** -Version 0.16.4 fixes disabled Flows that could resume execution after an asynchronous condition evaluation, hides disabled Flow outputs from control plans and schedules, resets timing gates on re-enable, and releases stale Flow thermostat intent. +Version 0.16.5 adds a temperature-origin explanation in the zone/thermostat UI, transparent room-sensor diagnostics, and a fail-safe that pauses automatic heating when HA sensor readings cannot be trusted. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > Built-in API: `/api/*` and `/ws`; interactive Swagger docs: `/api-docs`; OpenAPI JSON: `/api-docs/openapi.json`. @@ -306,7 +306,7 @@ In standalone mode, the Home Assistant connection test checks only `HA_URL` and - `home_assistant` — Home Assistant room sensor, - `combined` — weighted GREE + Home Assistant value. -For `home_assistant` and `combined`, each zone must set its own `ha_entity_id` room-temperature sensor. Room sensors are independent from the outdoor-temperature source and stale/unavailable room data falls back to the GREE sensor when possible. +For `home_assistant` and `combined`, each zone must set its own `ha_entity_id` room-temperature sensor. Room sensors are independent from the outdoor-temperature source. Stale/unavailable room data falls back to GREE as a **display reading**, but automatic **heating is paused** rather than started or boosted from that fallback. Already-running automatic heating is powered off when compressor protection permits; cooling, explicit manual operation and GREE-only zones retain their existing behavior. The guard also applies when `combined` readings disagree beyond the configured maximum difference. The UI includes a temperature-origin help tooltip and a sensor-safeguard status. Where Home Assistant supplies `last_reported`, it takes precedence over `last_updated` for sensor freshness, so a stable temperature repeatedly reported by HA is not incorrectly treated as stale. `HA_OUTDOOR_ENTITY_ID` is the global outdoor-temperature source used by outdoor-temperature assist. A zone may leave `ha_outdoor_entity_id` empty to use that global sensor, or set `ha_outdoor_entity_id` to use a different outdoor sensor only for that zone. If a zone override is unavailable, the controller falls back to the global outdoor source. Global and per-zone outdoor sensors are exposed through the normal alias/history/metric paths and appear in Home Assistant entity suggestions used by Visual Flows. diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index 5af6dd6..4c4de39 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -1,16 +1,16 @@ de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example 2fd3bf1fb7ef548c7abc9b26d1cc4ef470784b8637af65446b845b27a45b547b ./.gitignore -147eed97b308e0c27d6a5ee738a0ee710fa6ba2c6bbf7503df0fe484e0c7f842 ./Cargo.lock -c604af0060aef4e79d2e11fd1e1222a2444cebc835bc50629cff3d2373c5a05a ./Cargo.toml +795c232a7845a217a5711da4d9547282ba7f42fd160a249df07d631fac83928b ./Cargo.lock +2c57e2334ace9e7e320c97b14e1b0372c881717dad4bf3e6ef6abae252ed8056 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -75a6652611273ae458cb3509401cdb196d1084db1e49496ecdce448f490e6249 ./README.md +eb8dd4d456419762d78e8d426326ac33fa99dbf857ab0f34f52dadc30e8a62be ./README.md 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs -bb9840a0dc2b89d057d1461ae581ee61ebf856e3b6d38bc7d1bdd111eef7c550 ./docs/API.md +e961deaaa82be18f9e2933999dda33f55bfc648200e62043b5e8fffdfdd4464c ./docs/API.md 4f9826f1bf3b42ed2d62d8a8e9221b50175b3a9fdda7e9d219351f7a33039257 ./docs/FLOW.md -52c8dc48aed987fd28b2b543af1e5a782efb2a44104ccb0fd6b1740255cf384e ./docs/openapi.json -d8a1b3e34cf8a79ac3296bf600ad47826d639cdef4c1c2c099f8b6b21a391ebd ./flows/simple-heating/igor-simple-heat-v5.flow.json -bd9a07b8ca4b090ad0089e9037492cc25c47f3456b97ec08152947314ea904a6 ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json -c4e6e3e8df5eb7f9730a79fb90edd155f0510f7bb4862a04b9f35ba649c22376 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json +5c24d9f03e5025eda289e6862a7919fe3951992f7120647dc6e0cd0f1f9aa96f ./docs/openapi.json +a5ad73f7933031dfaa99aa92e27f10223d53fb84b14ee0a378eb5f10eb80e8c8 ./flows/simple-heating/igor-simple-heat-v5.flow.json +9743f4328a18bc9d89d10a9011d13ca201a4f352db2119335a66a2147a9a486d ./flows/simple-heating/jan-pieczara-simple-heat-v5.flow.json +2d804831a72577e80dc1ec3cf4f86dc8bcf00b1d8c80929896f11da0230b2b07 ./flows/simple-heating/sypialnia-simple-heat-v5.flow.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md @@ -24,7 +24,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -e8d079ebe63cd2916bd219cd45f583940e3d7ba5769af8deb1038415084a1a35 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +4ecd424fd1969a1bcd81eb85ea5ac50295a0d7691d50013027a7c2e400aec449 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -32,10 +32,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -904b21f54c7af0f59d91016d8513baaea5e927a349e18207f4290c51d7f2be8a ./ha-addon/repository/gree-controller/CHANGELOG.md +a4ae5bfa385365ef5db5332f2635a77e212a82e00a3cf473c5617ad134d3133e ./ha-addon/repository/gree-controller/CHANGELOG.md 50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md e3834bf712d4cd2acc715d8c85db96dd6e2fb3fbb758dac537f6034c337b3745 ./ha-addon/repository/gree-controller/README.md -efe06cb35044b19474ae11e5ef42e3201787ff1d164087cdbdb845a5ad6a16d8 ./ha-addon/repository/gree-controller/config.yaml +1fa36a0a0a3219604073950cd936c68fa6de1f51c031443ab22bf66596e0733f ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -45,8 +45,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -4bf92ee5627be67b66d76a6106b224159f95071f0aa2a727cd964e113bc3b32a ./lang/en.json -1e056cd915ba2e808db1287c99c06bbbbb3c98e5008481471b895e6ec20ea43a ./lang/pl.json +f3c1286c3758a98b6a2945562753262e9083a6c638a1dcae556c3e978b4a69af ./lang/en.json +ac6e6285a6153c5d8ce3c45d81ca6fe6c688505957e142e79e0ec0b496c400f3 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -102,6 +102,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw b1e7a7a7a1d6e21012808818ff1478f5116a3dc4c7da3650b57a719ca9df7873 ./scripts/test_flow_block_help_ui.js e3825cfd9e6e149fd227c521d886dc116170f2553d4d7639d61874267f257b6e ./scripts/test_flow_disable_ui.js 2942b34d82d06c950fbf167442fd1a25185de193055e02f2ce8dff62c2039add ./scripts/test_simple_heating_ui.js +9bef00c3a0956c5c893eff6cf1fee9681140a1e76da630fa5a347bce19f07981 ./scripts/test_temperature_explanation_ui.js b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh c260a6d291ad913f6aca60f23a0acf6928d68cfb0545b2a698d58c5a95fcf934 ./scripts/verify_simple_heating.py c32caf3ac7b521324d1d27233c271374ccf4efc3d243544ece727372c792e14a ./src/api.rs @@ -126,7 +127,7 @@ b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/publ fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs 7c974a98c72092886247f67c3a1c62736898d4b279ceb9c4c306690a3c6e4294 ./src/api/system.rs 3b0124a76dbc654584bcf186d43f89d20b6e7fd980ce0ffbf5a9e8eced5ec7f6 ./src/api/websocket.rs -eaa4070afbc19686d5e9bdb2d24fb2c7fc4bf1f90dc1c4f12fef17017d4cb975 ./src/api/zones.rs +79076544f4145c0eb120d544e9fab767b5c7ae625c2db007ee8be8a06bb0b85e ./src/api/zones.rs 32fa38d4315682cd3ef3ecdc91e571c2729b5e68b6f2bff965e43de3fdd2aeed ./src/config.rs de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs 87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs @@ -145,7 +146,7 @@ f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_ e6d6bf39e254bd2779d3bcb31d966aa3f6688ebfa91b4088b70fd796cad66596 ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs -62a15ea757c53796149e670863dae1b4e1a16ceb6ef88ed84ebd3089c0b9880d ./src/engine/control_plan.rs +c6b9651090e89fe0436826f66ae56825bf1e4b209ef8a27beb22fb1deaaa8c36 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 519f67ab805f61afea107215f5df50562fa1669a48334cc015570952e25e773a ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs @@ -157,13 +158,13 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r 48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs 9adfcb3c3dffbaa870b314904bd1995e7b78c3f12febd69e23433179e4bf3fba ./src/engine/simple_heat.rs 2694363e607b7f4407ed07c24019c457f0e0d093cb336ee2dc5cf77487c528f6 ./src/engine/targets.rs -d62ac5e97a36bbdb31bc23871ea7de73d8ac64a1e8203153d9faff0b1a075bc9 ./src/engine/temperature.rs +b35d7e96e50d40bf26be2e47e1fa1d393f4c07caeac41f3595f5d595579e55f7 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -4160811f47eca6e75970fe6c6b95198f86a4b97b7050531c19a19efb3d76d97d ./src/engine/tests.rs -07c3c462884344f018654267de3d454d895343b4699e34635e4d1ec88546df0a ./src/engine/zone_actions.rs -e246b6d6e3126941b989735a27e5fbe88dcaf351420aaaa060bb3761620d8244 ./src/engine/zone_control.rs +f563f2da2409bf291d67daa1b2b2fd6c7a463a3a11343afb08131d20cd26cd69 ./src/engine/tests.rs +63ded0ec62833699cbb7fcc6e4189ef19feafc23b9838d8fe9ba98889feb3e67 ./src/engine/zone_actions.rs +38256ec0e5ee735aa2f7ad7f8daaf2aa96077ea16a6c2cbceac43c4fe2bf8aa5 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs -d681f2aa53c39f37411834c300e9200299f94ee4ea021f44f70a3ea1774bd698 ./src/home_assistant.rs +8e181f29119ef6c32ee3821fe220ade97298c105c681c41466af0c16e539b7db ./src/home_assistant.rs bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs 2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs @@ -180,7 +181,7 @@ aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/f 17cac849056b677a3872e600a0d99218fb383ad5ec91cde596562df8e2f47508 ./src/models/runtime.rs 41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs -79cb16a9f12e8a20fcf0acc8649d771b8a76a05503c0372443d29003536c1bcb ./src/models/zone.rs +2b861f3b37274f4d0b9713706ae959220935bdaf63afc513329d1c48ac0f4dd9 ./src/models/zone.rs 4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs 7746bca683809b82529ed152272ae5c0cd3971758a02e076def58f770e7adf10 ./src/protocol/crypto.rs 578b9f39110abbeb98323044b6ae1a941733c82021afeb4d7ff78ad4256b5aa5 ./src/protocol/gree.rs @@ -206,16 +207,16 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -b030e61b30435495f492e15fa9daf228968cc1de2982e8f40a626452dbf5b81a ./web/css/styles.css +49d7992d8ebaf0b1eddd6538479b00a750897bc0454f3daa37eb01ed914bea8b ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -bbe640e99447f632383b2eb4b52da580ab95d60cd45cda47f6fae1c2380e5b8a ./web/index.html +bb1229db66ab2b510a44308e21796ba01f54c36238f5785ff7fc998dedd09c18 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js 07629b331bd498e8bea769d577321bbd12d9b076264242706d4ef16165fb22fa ./web/js-dynamic/core.js -ef20fb1e706105d0afdc2fa505d92ea52e43d93c9a687f2a09d381227a464564 ./web/js-dynamic/dashboard.js -3a6d2edd64fbdc2a8d468b05816366a9e05df284adea752c42a91e2a3a7bd786 ./web/js-dynamic/entities.js +1bf5b5e295f685b4c54064eb91220a50cb85264f7e90dfcd5bfe5c5e8ac111a2 ./web/js-dynamic/dashboard.js +4ddfe365f66b968fae490b19ab321e3497f9fba4e220123edd0a4e0353254d97 ./web/js-dynamic/entities.js bc1e7c4cccda0447fe805c61e655b773375bf503141195b82d4e95cdf093ec80 ./web/js-dynamic/events.js 3e2a5f1aab56e918624e6e7af064eecb9e94e554fd4d185715f8ecf3747a6895 ./web/js-dynamic/flows.js 1d54ae8919edb61414f9ffc46f85394b29c3c355827120264352cccb32f65e68 ./web/js-dynamic/forms.js diff --git a/docs/API.md b/docs/API.md index 1097d4d..fc9235b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -606,6 +606,14 @@ Important rules: The returned `Zone` also contains runtime state including sensor readings, resolved/effective setpoints, demand, current preset, manual overrides, local Quick Thermostat ownership, temporary session state, device-manual takeover, control owner/source/reason, lockout timestamps and `created_at`/`updated_at`. +Temperature diagnostics in `GET /api/zones` and zone WebSocket updates: + +- `control_temperature_source`: `device`, `external`, `combined`, `device_fallback`, `device_discrepancy_fallback`, `unavailable`. +- `room_sensor_status`: `not_used`, `ok`, `stale`, `error`, `discrepancy` (empty until the first cycle on older persisted zones). +- `heating_sensor_guard_active`: `true` when the thermostat would otherwise fall back to GREE while an automatic heating zone is configured for a Home Assistant room sensor. Automatic heating is paused, pending starts are cancelled, and existing automatic heat is switched OFF when compressor protection permits. The guard clears automatically with a valid room reading. Explicit manual control and GREE-only heating are unchanged. + +For a `combined` sensor strategy, the displayed control temperature is GREE × (1 − `external_sensor_weight`) + room × `external_sensor_weight`, rounded to 0.1°C, unless the difference exceeds `max_sensor_difference`. The Web UI provides this explanation in the zone and thermostat temperature help. Home Assistant sample freshness prefers `last_reported` when supplied and otherwise uses `last_updated`. + ### `GET /api/zones` Returns `Zone[]`. diff --git a/docs/openapi.json b/docs/openapi.json index ac9c462..8a56b5c 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.16.4", + "version": "0.16.5", "summary": "Local HTTP/WebSocket API for GREE Controller", "description": "Local API used by the GREE Controller Web UI and Home Assistant integration.", "license": { @@ -6079,7 +6079,7 @@ }, "version": { "type": "string", - "example": "0.16.4" + "example": "0.16.5" }, "uptime_seconds": { "type": "integer", @@ -7267,6 +7267,19 @@ "null" ] }, + "control_temperature_source": { + "type": "string", + "description": "Temperature source selected this cycle: device, external, combined, device_fallback, device_discrepancy_fallback or unavailable." + }, + "room_sensor_status": { + "type": "string", + "enum": ["not_used", "ok", "stale", "error", "discrepancy"], + "description": "Room sensor diagnostic for the latest zone cycle; empty for legacy zones not yet cycled." + }, + "heating_sensor_guard_active": { + "type": "boolean", + "description": "True when automatic heating is paused due to an untrusted room sensor; the unit is turned OFF as compressor protection allows." + }, "effective_mode": { "type": "string" }, diff --git a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json index 2dd93a0..56baf19 100644 --- a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json +++ b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.16.4", + "version": "0.16.5", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index aeff2e6..125a778 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.16.5 + +- Shows an accessible "Where does this temperature come from?" explanation on each zone and dashboard thermostat: weighted GREE/room formula, rounded result, source fallback and sensor status. +- Adds fail-safe automatic-heating control: missing, stale or excessively different HA room readings cannot start or boost heat from the GREE fallback. Existing automatic heating turns OFF once compressor minimum-on protection permits. Manual control, cooling and GREE-only configurations are unchanged. +- Displays blocked heating in the live thermostat status and "Why the controller works this way" control plan, including logs on guard activation/recovery. +- Uses Home Assistant `last_reported` for freshness when available (otherwise `last_updated`), to avoid treating repeatedly reported stable readings as stale. +- Extends the zone API diagnostics and ships Rust/UI regression tests plus the three day/night Flow examples. + ## 0.16.4 - Fixes a race in stateful Flow checkpoint saving: no stale condition evaluation can re-enable, overwrite or resurrect a disabled/deleted/recompiled automation. diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index c81b1bb..0c956e4 100644 --- a/ha-addon/repository/gree-controller/config.yaml +++ b/ha-addon/repository/gree-controller/config.yaml @@ -1,5 +1,5 @@ name: "GREE Controller" -version: "0.16.4" +version: "0.16.5" slug: "gree_controller" description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" diff --git a/lang/en.json b/lang/en.json index 770fb96..2960991 100644 --- a/lang/en.json +++ b/lang/en.json @@ -132,7 +132,7 @@ "zones.roomSensorEntity": "Room sensor entity_id", "zones.roomSensorWeight": "Room sensor weight %", "zones.maxDifference": "Max. sensor difference °C", - "zones.sensorHelp": "Required when the temperature strategy uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.", + "zones.sensorHelp": "Required when the temperature strategy uses Home Assistant. If readings are missing or disagree, GREE is shown as a fallback but automatic heating is paused. Explicit manual control is unaffected.", "zones.sensorRequired": "Enter the Home Assistant room temperature entity_id for this zone.", "zones.outdoorSensorEntity": "Outdoor temperature entity_id override", "zones.outdoorSensorHelp": "Leave empty to use the global Outdoor temperature entity_id. Set another entity_id to use a different outdoor sensor only for this zone. It is used by outdoor-temperature assist and recorded in history/metrics.", @@ -145,6 +145,25 @@ "zones.sourceFallback": "GREE fallback", "zones.sourceDiscrepancy": "GREE fallback: sensor mismatch", "zones.sourceUnavailable": "unavailable", + "zones.temperatureHelp": "Where does this temperature come from?", + "zones.temperatureFormula": "Weighted average: GREE {gree} × {greeWeight}% + room {room} × {roomWeight}% = {used} (rounded to 0.1°C).", + "zones.temperatureFromRoom": "Control temperature {room} comes from the room sensor. The GREE reading {gree} does not affect it.", + "zones.temperatureFromGree": "Control temperature {gree} comes only from GREE.", + "zones.temperatureDiscrepancy": "Sensors differ by more than {limit}. GREE {gree}, room {room}; using GREE as fallback.", + "zones.temperatureFallback": "{reason} Temperature {gree} comes from the GREE fallback.", + "zones.temperatureUnavailable": "No reliable temperature measurement is available.", + "zones.sensorStale": "The room sensor reading is stale.", + "zones.sensorUnavailable": "The room sensor is unavailable or returned an error.", + "zones.sensorStatusLabel": "sensor: {status}", + "zones.sensorStatusOk": "OK", + "zones.sensorStatusStale": "stale", + "zones.sensorStatusError": "unavailable", + "zones.sensorStatusDiscrepancy": "readings disagree", + "zones.heatGuardActive": "Heating sensor safeguard active.", + "zones.heatGuardHint": "Automatic heating is paused until a valid room reading returns.", + "zones.heatGuardPendingOff": "Heating is paused; the controller will turn the unit off when compressor protection allows or retry.", + "zones.heatGuardExplanation": "The fallback GREE reading is for display only. Automatic control will not start or boost heating from it.", + "plan.reasonRoomSensorGuard": "Automatic heating paused: no trustworthy room sensor reading.", "schedules.calendar": "Calendar", "schedules.weekdays": "Weekdays", "schedules.crossMidnight": "Intervals crossing midnight are supported", diff --git a/lang/pl.json b/lang/pl.json index be2644d..0d3e442 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -132,7 +132,7 @@ "zones.roomSensorEntity": "entity_id czujnika temperatury pokojowej", "zones.roomSensorWeight": "Waga czujnika pokojowego %", "zones.maxDifference": "Maks. różnica czujników °C", - "zones.sensorHelp": "Wymagany, gdy strategia temperatury używa Home Assistant. Gdy czujnik będzie niedostępny, kontroler wróci do sensora GREE.", + "zones.sensorHelp": "Wymagany, gdy strategia temperatury używa Home Assistant. Gdy odczyt jest niedostępny lub rozbieżny, GREE służy jako odczyt awaryjny, ale automatyczne grzanie zostaje wstrzymane. Sterowanie ręczne nie jest blokowane.", "zones.sensorRequired": "Podaj entity_id czujnika temperatury pokojowej z Home Assistant dla tej strefy.", "zones.outdoorSensorEntity": "Nadpisanie entity_id temperatury zewnętrznej", "zones.outdoorSensorHelp": "Pozostaw puste, aby użyć globalnego Outdoor temperature entity_id. Wybierz inne entity_id, aby tylko ta strefa używała innego czujnika zewnętrznego. Czujnik jest używany przez wspomaganie temperaturą zewnętrzną oraz zapisywany w historii i metrykach.", @@ -145,6 +145,25 @@ "zones.sourceFallback": "fallback GREE", "zones.sourceDiscrepancy": "fallback GREE: rozbieżność", "zones.sourceUnavailable": "niedostępna", + "zones.temperatureHelp": "Skąd ta temperatura?", + "zones.temperatureFormula": "Średnia ważona: GREE {gree} × {greeWeight}% + pokój {room} × {roomWeight}% = {used} (wynik zaokrąglony do 0,1°C).", + "zones.temperatureFromRoom": "Temperatura sterująca {room} pochodzi z czujnika pokojowego. Odczyt GREE {gree} nie wpływa na wynik.", + "zones.temperatureFromGree": "Temperatura sterująca {gree} pochodzi wyłącznie z GREE.", + "zones.temperatureDiscrepancy": "Czujniki różnią się o więcej niż {limit}. GREE {gree}, pokój {room}; awaryjnie wybrano GREE.", + "zones.temperatureFallback": "{reason} Temperatura {gree} pochodzi awaryjnie z GREE.", + "zones.temperatureUnavailable": "Brak wiarygodnego pomiaru temperatury.", + "zones.sensorStale": "Odczyt czujnika pokojowego jest nieaktualny.", + "zones.sensorUnavailable": "Czujnik pokojowy jest niedostępny lub zgłosił błąd.", + "zones.sensorStatusLabel": "czujnik: {status}", + "zones.sensorStatusOk": "OK", + "zones.sensorStatusStale": "nieaktualny", + "zones.sensorStatusError": "niedostępny", + "zones.sensorStatusDiscrepancy": "rozbieżność odczytów", + "zones.heatGuardActive": "Zabezpieczenie grzania aktywne.", + "zones.heatGuardHint": "Automatyczne grzanie wstrzymane do powrotu prawidłowego odczytu pokojowego.", + "zones.heatGuardPendingOff": "Grzanie wstrzymane; sterownik wyłączy jednostkę po spełnieniu ochrony sprężarki lub ponowi próbę.", + "zones.heatGuardExplanation": "Odczyt awaryjny GREE służy tylko do prezentacji. Automatyka nie uruchomi ani nie zwiększy grzania na jego podstawie.", + "plan.reasonRoomSensorGuard": "Automatyczne grzanie wstrzymane: brak wiarygodnego odczytu czujnika pokojowego.", "schedules.calendar": "Kalendarz", "schedules.weekdays": "Dni tygodnia", "schedules.crossMidnight": "Obsługa przedziałów przez północ", diff --git a/scripts/test_temperature_explanation_ui.js b/scripts/test_temperature_explanation_ui.js new file mode 100644 index 0000000..c470e16 --- /dev/null +++ b/scripts/test_temperature_explanation_ui.js @@ -0,0 +1,49 @@ +// Focused UI checks for zone temperature origin and sensor-safety indicators. +// Run from the project root: node scripts/test_temperature_explanation_ui.js +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const dict = JSON.parse(fs.readFileSync('lang/pl.json', 'utf8')).translations; +const ctx = vm.createContext({ + app: { zones: [], devices: [], groups: [] }, + tr: (key, vars = {}) => String(dict[key] || key).replace(/\{(\w+)\}/g, (match, name) => vars[name] ?? match), + fmtTemp: value => value == null ? '--' : `${Number(value).toFixed(1)}°C`, + uiIcon: name => ``, + esc: value => String(value ?? '').replace(/[&<>'"]/g, x => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[x]), +}); +vm.runInContext(fs.readFileSync('web/js-dynamic/entities.js', 'utf8'), ctx); + +const combined = { + sensor_source: 'combined', + control_temperature_source: 'combined', + device_temperature: 19, + external_temperature: 21, + external_sensor_weight: 0.8, + current_temperature: 20.6, + room_sensor_status: 'ok', + enabled: true, +}; +const formula = ctx.zoneTemperatureExplanation(combined); +assert.match(formula, /GREE 19\.0°C × 20%/); +assert.match(formula, /pokój 21\.0°C × 80% = 20\.6°C/); +const help = ctx.zoneTemperatureHelp(combined); +assert.match(help, /
Result Result Result bool { + mode == "heat" + && zone.enabled + && zone.control_owner == "automation" + && matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") + && !matches!(zone.control_temperature_source.as_str(), "external" | "combined") +} + fn adjustment_allowed(zone: &Zone) -> bool { let Some(last) = zone.last_action_at else { return true; diff --git a/src/engine/tests.rs b/src/engine/tests.rs index e30e2fe..a5db87a 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -770,6 +770,8 @@ mod tests { external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), + room_sensor_status: "not_used".into(), + heating_sensor_guard_active: false, active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, @@ -1502,6 +1504,45 @@ mod tests { assert!(!discrepancy); } + #[test] + fn room_sensor_guard_blocks_only_automatic_heating_on_untrusted_fallback() { + let mut zone = test_zone("combined"); + zone.control_temperature_source = "device_fallback".into(); + assert!(automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.control_temperature_source = "device_discrepancy_fallback".into(); + assert!(automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.control_temperature_source = "unavailable".into(); + assert!(automatic_heating_needs_room_sensor_guard(&zone, "heat")); + + zone.control_temperature_source = "combined".into(); + assert!(!automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.control_temperature_source = "external".into(); + assert!(!automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.control_temperature_source = "device_fallback".into(); + assert!(!automatic_heating_needs_room_sensor_guard(&zone, "cool")); + zone.control_owner = "local_thermostat".into(); + assert!(!automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.control_owner = "automation".into(); + zone.sensor_source = "device".into(); + assert!(!automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.sensor_source = "home_assistant".into(); + assert!(automatic_heating_needs_room_sensor_guard(&zone, "heat")); + zone.enabled = false; + assert!(!automatic_heating_needs_room_sensor_guard(&zone, "heat")); + } + + #[test] + fn zone_sensor_guard_fields_default_when_loading_existing_zone() { + let zone = test_zone("combined"); + let mut payload = serde_json::to_value(&zone).unwrap(); + let object = payload.as_object_mut().unwrap(); + object.remove("room_sensor_status"); + object.remove("heating_sensor_guard_active"); + let migrated: Zone = serde_json::from_value(payload).unwrap(); + assert_eq!(migrated.room_sensor_status, ""); + assert!(!migrated.heating_sensor_guard_active); + } + #[test] fn seasonal_profiles_resolve_independently() { let zone = test_zone("device"); diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 4594fab..f8e550b 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -37,6 +37,7 @@ fn zone_runtime_change_affects_control_plan(before: &Zone, after: &Zone) -> bool "heat_away_setpoint": zone.heat_away_setpoint, "current_temperature": zone.current_temperature, "control_temperature_source": zone.control_temperature_source, + "heating_sensor_guard_active": zone.heating_sensor_guard_active, "manual_preset": zone.manual_preset, "manual_setpoint": zone.manual_setpoint, "manual_override_until": zone.manual_override_until, @@ -111,6 +112,9 @@ fn persist_zone_cycle( latest.external_temperature = computed.external_temperature; latest.current_temperature = computed.current_temperature; latest.control_temperature_source = computed.control_temperature_source.clone(); + latest.room_sensor_status = computed.room_sensor_status.clone(); + // Guard state is a controller decision, not just a sensor observation; do not + // overwrite a newer manual/configuration change with a stale cycle's decision. latest.updated_at = Utc::now(); state.db.save_zone(&latest)?; } diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 0f09fe8..042dd0a 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -206,6 +206,11 @@ fn refresh_zone_temperature( room_sensor_results: &mut HashMap, Result)>, ) -> (String, bool) { let previous_source = zone.control_temperature_source.clone(); + let mut room_sensor_status = if zone.sensor_source == "device" { + "not_used" + } else { + "error" + }; let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { device.current_temperature @@ -216,6 +221,7 @@ fn refresh_zone_temperature( if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { match room_sensor_results.remove(&zone.id) { Some((resolved_entity, Ok(value))) => { + room_sensor_status = "ok"; if let Some(entity_id) = resolved_entity.as_deref() { record_ha_history( state, @@ -229,6 +235,11 @@ fn refresh_zone_temperature( Some(value) } Some((resolved_entity, Err(err))) => { + room_sensor_status = if err.contains("Home Assistant sensor is stale:") { + "stale" + } else { + "error" + }; if !matches!( previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback" @@ -262,10 +273,14 @@ fn refresh_zone_temperature( }; let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature); + if discrepancy { + room_sensor_status = "discrepancy"; + } zone.device_temperature = device_temperature; zone.external_temperature = external_temperature; zone.current_temperature = temperature; zone.control_temperature_source = source; + zone.room_sensor_status = room_sensor_status.into(); zone.updated_at = Utc::now(); (previous_source, discrepancy) } @@ -743,6 +758,8 @@ async fn control_zones(state: &AppState) -> Result<()> { refresh_control_ownership(&mut zone); let effective_mode = effective_mode_owned.as_str(); + let was_heating_sensor_guard_active = zone.heating_sensor_guard_active; + zone.heating_sensor_guard_active = false; let (previous_source, discrepancy) = refresh_zone_temperature( state, &settings, @@ -902,6 +919,66 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.active_preset = preset; zone.effective_setpoint = Some(target); + if automatic_heating_needs_room_sensor_guard(&zone, effective_mode) { + // Failing over to GREE is useful for displaying a temperature, but its + // lower reading must never start or boost automatic heating when the + // configured room sensor is stale/missing or disagrees excessively. + // Physically stop existing automatic heat once compressor protection allows. + zone.heating_sensor_guard_active = true; + zone.heat_coasting = false; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + zone.simple_heat_runtime = SimpleHeatRuntime::default(); + clear_compressor_pending(&mut zone, true); + zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + if !was_heating_sensor_guard_active { + state.log("warn", "zone.heating_sensor_guard", &format!( + "Automatic heating paused for {}: room sensor {} (using {} temperature for display)", + zone.name, zone.room_sensor_status, zone.control_temperature_source, + ), json!({ + "zone_id": zone.id, + "room_sensor_status": zone.room_sensor_status, + "control_temperature_source": zone.control_temperature_source, + "ha_entity_id": zone.ha_entity_id, + })); + } + if device.power && matches!(device.mode.as_str(), "heat" | "auto") + && device.online && device.communication_failures == 0 + { + let protection = chrono::Duration::seconds( + zone.min_on_seconds.max(settings.compressor_protection_seconds).max(30) as i64, + ); + let safe_to_switch_off = !settings.compressor_protection_enabled + || zone.last_power_change_at.is_none_or(|at| + Utc::now().signed_duration_since(at) >= protection); + if safe_to_switch_off { + match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { + power: Some(false), ..Default::default() + }).await { + Ok(Some(updated_device)) => { + if !updated_device.power { + zone.last_power_change_at = Some(Utc::now()); + zone.last_action_at = Some(Utc::now()); + zone.device_setpoint = None; + } + } + Ok(None) => {} + Err(err) => state.log("warn", "zone.heating_sensor_guard_off_error", &err.to_string(), + json!({"zone_id": zone.id})), + } + } + } + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + publish_persisted_zone_cycle(state, persist_zone_cycle(state, &zone, cycle_started_at)?)?; + continue; + } + if was_heating_sensor_guard_active { + state.log("info", "zone.heating_sensor_guard_released", &format!( + "Automatic heating sensor guard cleared for {} (sensor recovered or control configuration changed)", zone.name, + ), json!({"zone_id": zone.id, "room_sensor_status": zone.room_sensor_status})); + } + let Some(temp) = zone.current_temperature else { record_zone_history( state, diff --git a/src/home_assistant.rs b/src/home_assistant.rs index f6c08c2..a7b3544 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -227,17 +227,7 @@ pub async fn read_temperature( .await .context("invalid Home Assistant JSON")?; if let Some(limit) = stale_after_seconds.filter(|value| *value > 0) { - let updated = payload - .get("last_updated") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("Home Assistant last_updated is missing"))?; - let updated = chrono::DateTime::parse_from_rfc3339(updated) - .context("invalid Home Assistant last_updated")? - .with_timezone(&chrono::Utc); - let age = chrono::Utc::now() - .signed_duration_since(updated) - .num_seconds() - .max(0) as u64; + let age = temperature_sample_age_seconds(&payload, chrono::Utc::now())?; if age > limit { bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)") } @@ -256,9 +246,27 @@ pub async fn read_temperature( if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") { temperature = (temperature - 32.0) * 5.0 / 9.0; } + if !temperature.is_finite() { + bail!("Home Assistant temperature must be finite") + } Ok((temperature * 10.0).round() / 10.0) } +fn temperature_sample_age_seconds(payload: &Value, now: chrono::DateTime) -> Result { + // last_updated may stay unchanged while a sensor repeatedly reports the same + // temperature. Prefer last_reported (when HA provides it) for freshness, + // and retain last_updated compatibility with older HA versions. + let timestamp = payload + .get("last_reported") + .and_then(Value::as_str) + .or_else(|| payload.get("last_updated").and_then(Value::as_str)) + .ok_or_else(|| anyhow!("Home Assistant sensor timestamp is missing"))?; + let reported_at = chrono::DateTime::parse_from_rfc3339(timestamp) + .context("invalid Home Assistant sensor timestamp")? + .with_timezone(&chrono::Utc); + Ok(now.signed_duration_since(reported_at).num_seconds().max(0) as u64) +} + #[cfg(test)] mod tests { use super::*; @@ -279,6 +287,20 @@ mod tests { } } + #[test] + fn unchanged_temperature_can_be_fresh_when_last_reported_is_recent() { + let now = chrono::Utc::now(); + let payload = serde_json::json!({ + "last_updated": (now - chrono::Duration::minutes(16)).to_rfc3339(), + "last_reported": (now - chrono::Duration::seconds(45)).to_rfc3339(), + }); + assert_eq!(temperature_sample_age_seconds(&payload, now).unwrap(), 45); + let old_payload = serde_json::json!({ + "last_updated": (now - chrono::Duration::minutes(16)).to_rfc3339(), + }); + assert_eq!(temperature_sample_age_seconds(&old_payload, now).unwrap(), 960); + } + #[test] fn aliases_never_replace_real_home_assistant_entity_ids() { let settings = settings(); diff --git a/src/models/zone.rs b/src/models/zone.rs index b30c483..b511bbd 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -114,6 +114,14 @@ pub struct Zone { /// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`. #[serde(default = "default_control_temperature_source")] pub control_temperature_source: String, + /// Room sensor diagnostic from the latest zone cycle: not_used, ok, stale, error or discrepancy. + /// It is intentionally distinct from the selected control temperature source. + #[serde(default)] + pub room_sensor_status: String, + /// Automatic heating is suspended because a zone configured to use a room sensor + /// currently has no trustworthy room-sensor reading. Manual operation is unaffected. + #[serde(default)] + pub heating_sensor_guard_active: bool, /// Effective profile currently used by the zone: comfort/sleep/away/custom. #[serde(default = "default_active_preset")] pub active_preset: String, diff --git a/web/css/styles.css b/web/css/styles.css index a2e2b9f..be77f11 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -5128,6 +5128,64 @@ body.simulation-standalone [data-view="simulation"] { letter-spacing: -.03em; } +.zone-temperature-help { + margin-top: 5px; + min-width: 0; + font-size: 11px; +} + +.zone-temperature-help summary { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + list-style: none; + color: var(--accent); + font-size: 11px; + font-weight: 600; + line-height: 1.4; +} + +.zone-temperature-help summary::-webkit-details-marker { display: none; } + +.zone-temperature-help summary .ui-icon { width: 14px; height: 14px; flex: 0 0 auto; } + +.zone-temperature-help summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } + +.zone-temperature-help-content { + margin-top: 6px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface); + color: var(--text); + line-height: 1.5; + overflow-wrap: anywhere; + font-weight: 400; +} + +.zone-temperature-help-content p { margin: 6px 0 0; } + +.zone-temperature-guard-detail { margin: 6px 0 0; color: var(--warning); } + +.zone-heating-sensor-guard { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 10px; + margin: 0; + border: 1px solid var(--warning); + border-radius: 10px; + background: var(--warning-soft); + color: var(--text); + font-size: 11px; + line-height: 1.45; +} + +.zone-heating-sensor-guard .ui-icon { width: 16px; height: 16px; flex: 0 0 auto; color: var(--warning); } + +.zone-heating-sensor-guard strong { font-weight: 700; } + .zone-config-runtime { display: grid; justify-items: end; diff --git a/web/index.html b/web/index.html index 7e9f079..6d3d046 100644 --- a/web/index.html +++ b/web/index.html @@ -1621,7 +1621,7 @@ data-i18n="zones.maxDifference">Max. sensor difference °C

This room sensor is required when the temperature strategy - uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.

+ uses Home Assistant. Missing or disagreeing readings pause automatic heating; manual control is unaffected.

diff --git a/web/js-dynamic/dashboard.js b/web/js-dynamic/dashboard.js index a563444..d931c52 100644 --- a/web/js-dynamic/dashboard.js +++ b/web/js-dynamic/dashboard.js @@ -81,6 +81,7 @@ function planBlockedReasonLabel(reason) { manual_override: 'plan.reasonManual', zone_disabled: 'plan.reasonDisabled', offline: 'plan.reasonOffline', + heating_room_sensor_guard: 'plan.reasonRoomSensorGuard', no_active_thermostat_intent: 'plan.reasonNoIntent', }[reason]; return key ? tr(key) : reason; diff --git a/web/js-dynamic/entities.js b/web/js-dynamic/entities.js index 161f922..e9cb2ef 100644 --- a/web/js-dynamic/entities.js +++ b/web/js-dynamic/entities.js @@ -331,6 +331,59 @@ function zoneControlSourceLabel(source) { })[source] || source || tr('zones.sourceUnavailable'); } +function zoneTemperatureExplanation(zone) { + const gree = fmtTemp(zone.device_temperature); + const room = fmtTemp(zone.external_temperature); + const used = fmtTemp(zone.current_temperature); + const source = zone.control_temperature_source; + if (source === 'combined' && zone.device_temperature != null && zone.external_temperature != null) { + const roomWeight = Math.round(Math.max(0, Math.min(1, Number(zone.external_sensor_weight ?? 0.4))) * 100); + return tr('zones.temperatureFormula', { gree, room, used, greeWeight: 100 - roomWeight, roomWeight }); + } + if (source === 'external') { + return tr('zones.temperatureFromRoom', { room, gree }); + } + if (source === 'device') { + return tr('zones.temperatureFromGree', { gree }); + } + if (source === 'device_discrepancy_fallback') { + return tr('zones.temperatureDiscrepancy', { gree, room, limit: fmtTemp(zone.max_sensor_difference) }); + } + if (source === 'device_fallback') { + const reason = zone.room_sensor_status === 'stale' + ? tr('zones.sensorStale') + : tr('zones.sensorUnavailable'); + return tr('zones.temperatureFallback', { gree, reason }); + } + return tr('zones.temperatureUnavailable'); +} + +function zoneTemperatureHelp(zone) { + const explanation = zoneTemperatureExplanation(zone); + const guarded = zone.heating_sensor_guard_active === true; + const guardDetail = guarded ? tr('zones.heatGuardExplanation') : ''; + const sensorStatus = zoneRoomSensorStatusText(zone); + const tooltip = [explanation, sensorStatus, guardDetail].filter(Boolean).join(' '); + return `
${uiIcon('system-info')}${esc(tr('zones.temperatureHelp'))}
${esc(explanation)}${sensorStatus ? `

${esc(sensorStatus)}

` : ''}${guarded ? `

${esc(guardDetail)}

` : ''}
`; +} + +function zoneRoomSensorStatusText(zone) { + if (!zone || zone.sensor_source === 'device') return ''; + const key = { + ok: 'zones.sensorStatusOk', + stale: 'zones.sensorStatusStale', + error: 'zones.sensorStatusError', + discrepancy: 'zones.sensorStatusDiscrepancy', + }[zone.room_sensor_status]; + return key ? tr('zones.sensorStatusLabel', { status: tr(key) }) : ''; +} + +function zoneHeatingGuardNotice(zone, device) { + if (zone.heating_sensor_guard_active !== true) return ''; + const pending = device?.power && ['heat', 'auto'].includes(device?.mode); + return `

${uiIcon('system-info')}${esc(tr('zones.heatGuardActive'))} ${esc(tr(pending ? 'zones.heatGuardPendingOff' : 'zones.heatGuardHint'))}

`; +} + function zonePresetLabel(preset) { const key = `preset.${preset || 'comfort'}`; return tr(key) === key ? (preset || 'comfort') : tr(key); @@ -471,6 +524,7 @@ function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) { if (zone.device_manual_override === true) return tr('zones.manualDeviceControl'); if (!zone.enabled) return tr('common.disabled'); if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff'); + if (zone.heating_sensor_guard_active === true) return tr('zones.heatGuardActive'); if ((effectiveMode || 'off') === 'off') { if (zone.control_source === 'automation.flow' && zone.simple_heat && zone.control_owner === 'automation') return tr(device?.power ? 'zones.flowDayPendingOff' : 'zones.flowDayOff'); return tr('zones.waiting'); @@ -494,8 +548,9 @@ function zoneCard(zone, detailed = true) { const state = zone.enabled ? tr('common.active') : tr('common.disabled'); const sensorDetails = zone.sensor_source === 'device' ? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}` - : `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`; + : `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}${zoneRoomSensorStatusText(zone) ? ` · ${zoneRoomSensorStatusText(zone)}` : ''}`; const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature; + const temperatureTooltip = zoneTemperatureExplanation(zone); const manual = zone.manual_preset || 'auto'; 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'; @@ -556,7 +611,7 @@ function zoneCard(zone, detailed = true) { return `
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

-
${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}→${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
+
${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}→${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
${zoneTemperatureHelp(zone)}
${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
@@ -567,6 +622,7 @@ function zoneCard(zone, detailed = true) {
${esc(tr('zones.heatAnticipation'))}${Number(zone.heat_anticipation_c ?? 0).toFixed(1)}°C
${esc(sensorDetails)}
+ ${zoneHeatingGuardNotice(zone, device)} ${compressorQueuePanel(zone)} ${manualTakeover} @@ -577,7 +633,8 @@ function zoneCard(zone, detailed = true) { return `

${esc(zone.name)}

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

${localThermostatPowerControl}${temporaryThermostatControl}
${disabledManualControlNotice} -
${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)}${zoneTemperatureHelp(zone)}
${Number.isFinite(target) ? target.toFixed(1) : '--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
+ ${zoneHeatingGuardNotice(zone, device)}
${['auto', 'comfort', 'sleep', 'away'].map(preset => ``).join('')}