v0.9.5
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.9.4"
|
||||
version = "0.9.5"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.9.4"
|
||||
version = "0.9.5"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+40
-35
@@ -1,13 +1,12 @@
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
5330f2eb75251cee43aa853448290bccd0e0d95f89aa1d23bde498eb86e99699 ./build.rs
|
||||
37ba4d5222b221d9a3d560246d968092fa4402288410eaba7e6bab87ebcc5aea ./Cargo.lock
|
||||
a0976abed3b84a5ba0c726410c87d31017d42098fb7e053af986869ea0c9db74 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
a6391cc7fc014d06205c3fac708e0316e7239eaca47e0a2cb05a18275c58f192 ./README.md
|
||||
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
|
||||
bfa6891d9abc7ac013773e5c7533325981c6887a87af725001a80611bc02e3ea ./data/gree-controller.db
|
||||
e7f085a9b4bd75dd62609e3c9f62bdde532f5cb0675fda2cf5109e57fafef2d2 ./docs/API.md
|
||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
ad08bba75bbc38d4c48cecef9ee05a19320d9a6a20b6cc950fdbb8115da573f2 ./gree-controller.zip
|
||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||
71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
||||
@@ -15,6 +14,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista
|
||||
ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||
f30f751b4e561e51a3ff9bf9c8384bc2d160dd157b954dd7061d5844ee410b77 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||
@@ -24,22 +24,26 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
|
||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||
a3e5842856e956044aefe840eb14fa36f98ae31d5613a7ac9f546df96c6c2366 ./lang/en.json
|
||||
1baff114849223295ecf91251f525ec7e88f8261adcfa0462fa69ec01fe54517 ./lang/pl.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
a6391cc7fc014d06205c3fac708e0316e7239eaca47e0a2cb05a18275c58f192 ./README.md
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
|
||||
a0cc9f6c6fa7322c48b227b1e28521ac3abd3fde91d9931a2607f18d6adcd783 ./scripts/FILE_MANIFEST.sha256
|
||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs
|
||||
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
|
||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||
08a732a445f44d34c644109762ac389b58c96d0fe47b309ce34f4479d67c3712 ./src/api/automations.rs
|
||||
@@ -52,23 +56,23 @@ a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/hist
|
||||
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
|
||||
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
||||
2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs
|
||||
4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs
|
||||
8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs
|
||||
4ffab50d931ca46dc5708914b6b54256d639cc11ad1b4281f649c310db5d41a0 ./src/api/settings.rs
|
||||
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
|
||||
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
||||
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
|
||||
2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs
|
||||
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
|
||||
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
|
||||
3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs
|
||||
56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs
|
||||
d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs
|
||||
f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs
|
||||
59912e70b688daf5c4d22679d643ed3b724c262d62466aeaa9ef74db87808178 ./src/db/ha_history.rs
|
||||
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
|
||||
7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs
|
||||
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
||||
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
||||
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
||||
384f7027ca48bd4c4048320bc6d4cacd631bee40e8630ea22e0d6e5f22ddc3a6 ./src/engine/automations.rs
|
||||
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
|
||||
e16c1a65057f6fbf11bb6f27493ed975f5c89309b31dec0fa3248b9237494ed3 ./src/engine/control_plan.rs
|
||||
@@ -78,6 +82,7 @@ d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/g
|
||||
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
|
||||
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs
|
||||
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
|
||||
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
||||
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
|
||||
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
|
||||
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
|
||||
@@ -88,24 +93,23 @@ ebf8b9ec5c5a84850d8cad4c4faea090e52625b5a0be56625dc84060ecb35186 ./src/engine/z
|
||||
d2c2f3678bd13ec2d92404993173a691803da215dbdb1edafcd96e1963c7004e ./src/engine/zone_control.rs
|
||||
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
||||
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
|
||||
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
|
||||
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
||||
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
||||
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
|
||||
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
||||
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
|
||||
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
|
||||
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
|
||||
da672b06295370a36c69136e15684ab10ccb8fbdc814d76527a05e01213c2d2b ./src/models/control_plan.rs
|
||||
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
||||
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
||||
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
||||
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
|
||||
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
|
||||
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
|
||||
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
|
||||
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
|
||||
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
||||
c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs
|
||||
563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs
|
||||
5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs
|
||||
@@ -113,37 +117,38 @@ c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol
|
||||
97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs
|
||||
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
|
||||
45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs
|
||||
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
||||
35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs
|
||||
55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
|
||||
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
|
||||
cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/entities.rs
|
||||
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
|
||||
2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs
|
||||
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
|
||||
f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs
|
||||
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
||||
6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html
|
||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
44ce3d17b83c1ab505247ba050a923d5ae109a3a06209aa3d362e9d6daf1ecbf ./web/index.html
|
||||
525c7e83930cde6ea9d248220d595f52da4ade6e0f511e8b1075509635f20b32 ./web/index.html
|
||||
fbe1ff0e28708f3b4acdbd361ba28457fe127e6474f4def15125e9d195c929c0 ./web/js/bootstrap.js
|
||||
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
||||
80bcc8d2214c55c13574e146eb099cb371565e55c7c4b337b71ca57ad29f90d2 ./web/js/core.js
|
||||
83f6a5602da72ed6a307c1b4cc8cd9e4d9a8bf73e58be43af30ba19025a82ea3 ./web/js/dashboard.js
|
||||
40f2363e79e12446ec8750b56c6df063035cd9655ee89e7c536e3b1f6e71746c ./web/js/entities.js
|
||||
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
||||
cac7615888fd38be3cf714dcf02380c4a41a1802ee1724f91496e7c19cbdffba ./web/js/forms.js
|
||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||
72f2b4ef1e7de342b1d14a4b2757eb23d5a8acd6e837a0fe9fcc661dc35eeb63 ./web/js/navigation.js
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||
b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js
|
||||
64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js
|
||||
f3d2a8725f891266c725e3c67557de1f7305db00454ed4147a5c26a32cda31ff ./web/js/core.js
|
||||
9767d5f2bd2844245245c0bd86dddd528975c1d1dd1d05b1449feda7a95214c4 ./web/js/dashboard.js
|
||||
d0233628681160eb0c3d559887e48f0799b6ad0130bc943d0bea3e409ac05bf9 ./web/js/entities.js
|
||||
69a052771dfafbd4b778cbc9d1b7a7695427621ed66047282088a3a07e6e4c2e ./web/js/events.js
|
||||
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
|
||||
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
|
||||
9aa199a9ca8d1109cc06d7a7d69f5c5ed45c53f488128ca520258e9c91c31352 ./web/js/main.js
|
||||
e27d5bb4a1579fd5b1d9762a5067b953004ca8ae63506af4e8c2cad754c0c532 ./web/js/navigation.js
|
||||
40446c6bbfa21b645732bb56d7d8a1dd2d7e940b84fc882670d3a3607d127a55 ./web/js/realtime.js
|
||||
45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js
|
||||
f9d23d7142ebcd77085ad30b8b5bf927b754b51f10976309d5b0a3f22f3cfafb ./web/js/settings-ui.js
|
||||
a189f4f7cc253087ac271b3c5b7a82751176032fc0f7040f073cdc451d354e06 ./web/js/settings.js
|
||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||
a447e27f1c6f5cd126e4ea08781603afaca0877c495bc38095e192618efbd942 ./web/styles.css
|
||||
bc95830ec8273818ffde0a6b50eb3cc9ecbeee65a7a1d3aeff08cde86d05dc7b ./web/sw.js
|
||||
262208c2487399ebbc94747c4d71267b01b5c4ac5bb20c682fc926840e048828 ./web/js/realtime.js
|
||||
415348a93ac3423c491d5b222035a5ccd073eae9dc82e9c0560829afb6e02767 ./web/js/router.js
|
||||
4a05126dc95c291ede08452b179d77791e270ed0c9ab9806036934ccf7e0f704 ./web/js/settings.js
|
||||
b3e796778d6eee59d663f198115d0c2a45497fdfc23a88ff4b85893ba079d586 ./web/js/settings-ui.js
|
||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||
d2e533ca1a445036327095647d15eef89eb810295f4222cf7284ac31ee6b5e10 ./web/styles.css
|
||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::{env, fs, path::{Path, PathBuf}};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str {
|
||||
meta.get(key)
|
||||
@@ -8,7 +11,6 @@ fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str {
|
||||
.unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string"))
|
||||
}
|
||||
|
||||
|
||||
fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 {
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
@@ -69,14 +71,24 @@ fn main() {
|
||||
let sw_path = web_dir.join("sw.js");
|
||||
let manifest_path = web_dir.join("manifest.webmanifest");
|
||||
let favicon_path = web_dir.join("favicon.svg");
|
||||
for path in [&theme_init_path, &styles_path, &index_path, ¬_found_path, &sw_path, &manifest_path, &favicon_path] {
|
||||
for path in [
|
||||
&theme_init_path,
|
||||
&styles_path,
|
||||
&index_path,
|
||||
¬_found_path,
|
||||
&sw_path,
|
||||
&manifest_path,
|
||||
&favicon_path,
|
||||
] {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
|
||||
let app_js = bundle_app_js(&web_dir);
|
||||
let app_js_hash = content_hash(app_js.as_bytes());
|
||||
let theme_init_bytes = fs::read(&theme_init_path).unwrap_or_else(|error| panic!("cannot read {}: {error}", theme_init_path.display()));
|
||||
let styles_bytes = fs::read(&styles_path).unwrap_or_else(|error| panic!("cannot read {}: {error}", styles_path.display()));
|
||||
let theme_init_bytes = fs::read(&theme_init_path)
|
||||
.unwrap_or_else(|error| panic!("cannot read {}: {error}", theme_init_path.display()));
|
||||
let styles_bytes = fs::read(&styles_path)
|
||||
.unwrap_or_else(|error| panic!("cannot read {}: {error}", styles_path.display()));
|
||||
let theme_init_hash = content_hash(&theme_init_bytes);
|
||||
let styles_hash = content_hash(&styles_bytes);
|
||||
let app_js_asset_path = format!("/app-{}.js", &app_js_hash[..12]);
|
||||
@@ -84,8 +96,17 @@ fn main() {
|
||||
let styles_asset_path = format!("/styles-{}.css", &styles_hash[..12]);
|
||||
|
||||
let mut build_hash = fnv1a_update(0xcbf29ce484222325u64, app_js.as_bytes());
|
||||
for path in [&theme_init_path, &styles_path, &index_path, ¬_found_path, &sw_path, &manifest_path, &favicon_path] {
|
||||
let bytes = fs::read(path).unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
|
||||
for path in [
|
||||
&theme_init_path,
|
||||
&styles_path,
|
||||
&index_path,
|
||||
¬_found_path,
|
||||
&sw_path,
|
||||
&manifest_path,
|
||||
&favicon_path,
|
||||
] {
|
||||
let bytes = fs::read(path)
|
||||
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
|
||||
build_hash = fnv1a_update(build_hash, &bytes);
|
||||
}
|
||||
|
||||
@@ -107,9 +128,18 @@ fn main() {
|
||||
|
||||
for path in files {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let filename = path.file_name().and_then(|v| v.to_str()).expect("UTF-8 language filename");
|
||||
let stem = path.file_stem().and_then(|v| v.to_str()).expect("UTF-8 language code");
|
||||
if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.expect("UTF-8 language filename");
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|v| v.to_str())
|
||||
.expect("UTF-8 language code");
|
||||
if !stem
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'");
|
||||
}
|
||||
|
||||
@@ -118,15 +148,20 @@ fn main() {
|
||||
build_hash = fnv1a_update(build_hash, source.as_bytes());
|
||||
let document: Value = serde_json::from_str(&source)
|
||||
.unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}"));
|
||||
let root_object = document.as_object().unwrap_or_else(|| panic!("{filename}: language pack root must be an object"));
|
||||
let unexpected: Vec<&str> = root_object.keys()
|
||||
let root_object = document
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("{filename}: language pack root must be an object"));
|
||||
let unexpected: Vec<&str> = root_object
|
||||
.keys()
|
||||
.filter(|key| key.as_str() != "meta" && key.as_str() != "translations")
|
||||
.map(|key| key.as_str())
|
||||
.collect();
|
||||
if !unexpected.is_empty() {
|
||||
panic!("{filename}: unexpected top-level keys {:?}; translation keys must be inside 'translations'", unexpected);
|
||||
}
|
||||
let meta = document.get("meta").unwrap_or_else(|| panic!("{filename}: missing meta object"));
|
||||
let meta = document
|
||||
.get("meta")
|
||||
.unwrap_or_else(|| panic!("{filename}: missing meta object"));
|
||||
let code = required_string(meta, "code", filename);
|
||||
let name = required_string(meta, "name", filename);
|
||||
let native_name = required_string(meta, "native_name", filename);
|
||||
@@ -158,15 +193,31 @@ fn main() {
|
||||
let manifest_json = serde_json::to_string(&json!({
|
||||
"default": "en",
|
||||
"languages": manifest_languages
|
||||
})).expect("serialize language manifest");
|
||||
}))
|
||||
.expect("serialize language manifest");
|
||||
|
||||
let mut generated = String::new();
|
||||
generated.push_str("// @generated by build.rs - do not edit.\n");
|
||||
generated.push_str(&format!("pub const APP_JS_ASSET_PATH: &str = {:?};\n", app_js_asset_path));
|
||||
generated.push_str(&format!("pub const THEME_INIT_ASSET_PATH: &str = {:?};\n", theme_init_asset_path));
|
||||
generated.push_str(&format!("pub const STYLES_CSS_ASSET_PATH: &str = {:?};\n", styles_asset_path));
|
||||
generated.push_str(&format!("pub const ASSET_BUILD_ID: &str = \"{:016x}\";\n", build_hash));
|
||||
generated.push_str(&format!("pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", manifest_json));
|
||||
generated.push_str(&format!(
|
||||
"pub const APP_JS_ASSET_PATH: &str = {:?};\n",
|
||||
app_js_asset_path
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const THEME_INIT_ASSET_PATH: &str = {:?};\n",
|
||||
theme_init_asset_path
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const STYLES_CSS_ASSET_PATH: &str = {:?};\n",
|
||||
styles_asset_path
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const ASSET_BUILD_ID: &str = \"{:016x}\";\n",
|
||||
build_hash
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n",
|
||||
manifest_json
|
||||
));
|
||||
generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n");
|
||||
for (code, path) in assets {
|
||||
generated.push_str(&format!(
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
scripts/regenerate-sha.sh
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
MODE="debug"
|
||||
ACTION="run"
|
||||
INSTALL_DEPS=1
|
||||
RESET_DB=0
|
||||
HOST=""
|
||||
PORT=""
|
||||
|
||||
usage() {
|
||||
cat <<'TXT'
|
||||
GREE Controller - development environment
|
||||
|
||||
Usage:
|
||||
./scripts/dev.sh install missing tools, build and run
|
||||
./scripts/dev.sh --release run an optimized build
|
||||
./scripts/dev.sh --check formatting, tests, build and API smoke test
|
||||
./scripts/dev.sh --reset remove the local database before startup
|
||||
./scripts/dev.sh --no-install do not install system packages or Rust
|
||||
./scripts/dev.sh --host 0.0.0.0 --port 8787
|
||||
TXT
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--release) MODE="release"; shift ;;
|
||||
--check) ACTION="check"; shift ;;
|
||||
--reset) RESET_DB=1; shift ;;
|
||||
--no-install) INSTALL_DEPS=0; shift ;;
|
||||
--host) HOST="${2:?missing value for --host}"; shift 2 ;;
|
||||
--port) PORT="${2:?missing value for --port}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$INSTALL_DEPS" -eq 1 ]]; then
|
||||
install_build_dependencies
|
||||
ensure_rust
|
||||
else
|
||||
command -v cargo >/dev/null 2>&1 || fail "Cargo is not installed and --no-install was requested."
|
||||
fi
|
||||
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
say "Created .env from the example configuration"
|
||||
fi
|
||||
mkdir -p data
|
||||
if [[ "$RESET_DB" -eq 1 ]]; then
|
||||
rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal
|
||||
say "Removed the local database"
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source ./.env
|
||||
set +a
|
||||
|
||||
if [[ -n "$HOST" || -n "$PORT" ]]; then
|
||||
current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
|
||||
current_host="${current%:*}"
|
||||
current_port="${current##*:}"
|
||||
export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}"
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "check" ]]; then
|
||||
say "Checking formatting"
|
||||
cargo fmt --all -- --check
|
||||
say "Running Rust tests"
|
||||
cargo test --all-targets
|
||||
say "Building the application"
|
||||
cargo build
|
||||
say "Running HTTP/API smoke test"
|
||||
"$SCRIPT_DIR/smoke.sh"
|
||||
say "All checks passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "release" ]]; then
|
||||
say "Building release version"
|
||||
cargo build --release
|
||||
BINARY="$PROJECT_ROOT/target/release/gree-controller"
|
||||
else
|
||||
say "Building debug version"
|
||||
cargo build
|
||||
BINARY="$PROJECT_ROOT/target/debug/gree-controller"
|
||||
fi
|
||||
|
||||
bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
|
||||
display_host="${bind%:*}"
|
||||
[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1"
|
||||
say "Panel: http://${display_host}:${bind##*:}"
|
||||
say "Stop: Ctrl+C"
|
||||
exec "$BINARY"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MANIFEST="FILE_MANIFEST.sha256"
|
||||
TMP="${MANIFEST}.tmp"
|
||||
|
||||
find . -type f \
|
||||
! -path "./${MANIFEST}" \
|
||||
! -path "./${TMP}" \
|
||||
! -path "./.git/*" \
|
||||
! -path "./target/*" \
|
||||
-print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > "$TMP"
|
||||
|
||||
mv "$TMP" "$MANIFEST"
|
||||
|
||||
sha256sum -c "$MANIFEST"
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
@@ -10,6 +11,7 @@
|
||||
<script src="__GREE_THEME_INIT_ASSET__"></script>
|
||||
<title>Page not found · GREE Controller</title>
|
||||
</head>
|
||||
|
||||
<body class="error-page">
|
||||
<header class="topbar error-topbar">
|
||||
<div class="brand">
|
||||
@@ -29,4 +31,5 @@
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+860
-299
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
@@ -10,7 +10,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.automations = data.automations || [];
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = data.settings || null;
|
||||
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
app.system = data.system || {};
|
||||
app.systemSnapshotAt = Date.now();
|
||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||
|
||||
+250
-250
@@ -1,13 +1,13 @@
|
||||
function encodeChartSpec(series) {
|
||||
try {
|
||||
const raw = JSON.stringify(series || []);
|
||||
return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function decodeChartSpec(encoded) {
|
||||
try {
|
||||
const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
|
||||
const padded = String(encoded || '').replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
|
||||
const value = JSON.parse(atob(padded));
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : [];
|
||||
} catch (_) { return []; }
|
||||
@@ -22,7 +22,7 @@ function historyEntityOptions() {
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
].filter(Boolean))].sort();
|
||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).join('');
|
||||
return {zones, devices, sensors, entities};
|
||||
return { zones, devices, sensors, entities };
|
||||
}
|
||||
|
||||
function renderHistoryNavigation() {
|
||||
@@ -31,13 +31,13 @@ function renderHistoryNavigation() {
|
||||
const options = historyEntityOptions();
|
||||
if (app.historyTab === 'zones') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
|
||||
const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone;
|
||||
const select = $('#historyZoneSelect'); if ([...select.options].some(option => option.value === app.historyZone)) select.value = app.historyZone;
|
||||
} else if (app.historyTab === 'devices') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
|
||||
const select=$('#historyDeviceSelect'); if ([...select.options].some(option=>option.value===app.historyDevice)) select.value=app.historyDevice;
|
||||
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
|
||||
} else if (app.historyTab === 'sensors') {
|
||||
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
|
||||
const select=$('#historySensorSelect'); if ([...select.options].some(option=>option.value===app.historySensor)) select.value=app.historySensor;
|
||||
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
|
||||
} else if (app.historyTab === 'custom') {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.customHint'))}</span>`;
|
||||
} else {
|
||||
@@ -51,7 +51,7 @@ async function loadHistory() {
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
try {
|
||||
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
|
||||
app.historyData = {zones:data.zones || [], devices:data.devices || [], sensors:data.sensors || []};
|
||||
app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] };
|
||||
app.historyCounts = data.counts || {};
|
||||
renderHistoryNavigation();
|
||||
renderHistoryPage();
|
||||
@@ -62,7 +62,7 @@ async function loadHistory() {
|
||||
} finally { app.historyLoading = false; }
|
||||
}
|
||||
|
||||
const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue'];
|
||||
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
|
||||
const MAX_CHART_ZOOM = 16;
|
||||
const chartRuntime = new Map();
|
||||
|
||||
@@ -71,57 +71,57 @@ function chartSeriesKey(item, index) {
|
||||
}
|
||||
|
||||
function isChartSeriesHidden(chartId, item, index) {
|
||||
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item,index)];
|
||||
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item, index)];
|
||||
}
|
||||
|
||||
function setChartSeriesHidden(chartId, item, index, hidden) {
|
||||
if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {};
|
||||
const key = chartSeriesKey(item,index);
|
||||
const key = chartSeriesKey(item, index);
|
||||
if (hidden) app.chartHiddenSeries[chartId][key] = true;
|
||||
else delete app.chartHiddenSeries[chartId][key];
|
||||
}
|
||||
|
||||
function updateChartZoomControls(id) {
|
||||
const canvas=document.getElementById(id);
|
||||
const card=canvas?.closest('.history-chart-card');
|
||||
if(!card) return;
|
||||
const zoom=clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM);
|
||||
const reset=card.querySelector('[data-chart-zoom="reset"]');
|
||||
const out=card.querySelector('[data-chart-zoom="out"]');
|
||||
const zoomIn=card.querySelector('[data-chart-zoom="in"]');
|
||||
if(reset) reset.textContent=`${Math.round(zoom*100)}%`;
|
||||
if(out) out.disabled=zoom<=1;
|
||||
if(zoomIn) zoomIn.disabled=zoom>=MAX_CHART_ZOOM;
|
||||
const canvas = document.getElementById(id);
|
||||
const card = canvas?.closest('.history-chart-card');
|
||||
if (!card) return;
|
||||
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const reset = card.querySelector('[data-chart-zoom="reset"]');
|
||||
const out = card.querySelector('[data-chart-zoom="out"]');
|
||||
const zoomIn = card.querySelector('[data-chart-zoom="in"]');
|
||||
if (reset) reset.textContent = `${Math.round(zoom * 100)}%`;
|
||||
if (out) out.disabled = zoom <= 1;
|
||||
if (zoomIn) zoomIn.disabled = zoom >= MAX_CHART_ZOOM;
|
||||
}
|
||||
|
||||
function redrawHistoryChart(id) {
|
||||
const runtime=chartRuntime.get(id);
|
||||
const canvas=document.getElementById(id);
|
||||
if(!runtime || !canvas) return;
|
||||
canvas.dataset.chartZoom=String(clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM));
|
||||
drawLineChart(canvas,runtime.series,runtime.rows,runtime.options);
|
||||
renderLegend(document.getElementById(`${id}Legend`),runtime.series);
|
||||
const runtime = chartRuntime.get(id);
|
||||
const canvas = document.getElementById(id);
|
||||
if (!runtime || !canvas) return;
|
||||
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
|
||||
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
|
||||
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
|
||||
updateChartZoomControls(id);
|
||||
}
|
||||
|
||||
function setChartZoom(id, value, anchorRatio=null, anchorViewportX=null) {
|
||||
const canvas=document.getElementById(id);
|
||||
const wrap=canvas?.parentElement;
|
||||
if(!canvas || !wrap) return;
|
||||
const oldWidth=canvas.getBoundingClientRect().width || 1;
|
||||
const ratio=Number.isFinite(anchorRatio) ? clamp(anchorRatio,0,1) : clamp((wrap.scrollLeft+wrap.clientWidth/2)/oldWidth,0,1);
|
||||
const viewportX=Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth/2;
|
||||
const next=clamp(Number(value) || 1,1,MAX_CHART_ZOOM);
|
||||
app.chartZooms[id]=next;
|
||||
function setChartZoom(id, value, anchorRatio = null, anchorViewportX = null) {
|
||||
const canvas = document.getElementById(id);
|
||||
const wrap = canvas?.parentElement;
|
||||
if (!canvas || !wrap) return;
|
||||
const oldWidth = canvas.getBoundingClientRect().width || 1;
|
||||
const ratio = Number.isFinite(anchorRatio) ? clamp(anchorRatio, 0, 1) : clamp((wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth, 0, 1);
|
||||
const viewportX = Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth / 2;
|
||||
const next = clamp(Number(value) || 1, 1, MAX_CHART_ZOOM);
|
||||
app.chartZooms[id] = next;
|
||||
redrawHistoryChart(id);
|
||||
const nextCanvas=document.getElementById(id);
|
||||
const nextWrap=nextCanvas?.parentElement;
|
||||
if(!nextCanvas || !nextWrap) return;
|
||||
const nextWidth=nextCanvas.getBoundingClientRect().width || 1;
|
||||
const previousBehavior=nextWrap.style.scrollBehavior;
|
||||
nextWrap.style.scrollBehavior='auto';
|
||||
nextWrap.scrollLeft=Math.max(0,ratio*nextWidth-viewportX);
|
||||
nextWrap.style.scrollBehavior=previousBehavior;
|
||||
const nextCanvas = document.getElementById(id);
|
||||
const nextWrap = nextCanvas?.parentElement;
|
||||
if (!nextCanvas || !nextWrap) return;
|
||||
const nextWidth = nextCanvas.getBoundingClientRect().width || 1;
|
||||
const previousBehavior = nextWrap.style.scrollBehavior;
|
||||
nextWrap.style.scrollBehavior = 'auto';
|
||||
nextWrap.scrollLeft = Math.max(0, ratio * nextWidth - viewportX);
|
||||
nextWrap.style.scrollBehavior = previousBehavior;
|
||||
}
|
||||
|
||||
function cssColor(name, fallback) {
|
||||
@@ -130,7 +130,7 @@ function cssColor(name, fallback) {
|
||||
}
|
||||
|
||||
function timeLabel(ts, hours) {
|
||||
const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'};
|
||||
const options = Number(hours) > 48 ? { day: '2-digit', month: '2-digit', hour: '2-digit' } : { hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(ts).toLocaleString(locale(), options);
|
||||
}
|
||||
|
||||
@@ -142,157 +142,157 @@ function prepareCanvas(canvas, height) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr; canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
ctx.clearRect(0,0,width,height);
|
||||
return {ctx,width,height};
|
||||
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
return { ctx, width, height };
|
||||
}
|
||||
|
||||
function drawEmptyChart(canvas, height=340) {
|
||||
function drawEmptyChart(canvas, height = 340) {
|
||||
if (!canvas) return;
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center';
|
||||
ctx.fillText(tr('history.noData'), width/2, height/2);
|
||||
const wrap=canvas.parentElement;
|
||||
const tooltip=wrap?.querySelector('.chart-tooltip');
|
||||
const line=wrap?.querySelector('.chart-hover-line');
|
||||
if(tooltip) tooltip.hidden=true;
|
||||
if(line) line.hidden=true;
|
||||
canvas.onpointermove=null; canvas.onpointerleave=null; canvas.onpointerdown=null; canvas.onpointerup=null; canvas.onpointercancel=null; canvas.onkeydown=null;
|
||||
const { ctx, width } = prepareCanvas(canvas, height);
|
||||
ctx.fillStyle = cssColor('--muted', '#888'); ctx.font = '13px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(tr('history.noData'), width / 2, height / 2);
|
||||
const wrap = canvas.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
if (tooltip) tooltip.hidden = true;
|
||||
if (line) line.hidden = true;
|
||||
canvas.onpointermove = null; canvas.onpointerleave = null; canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onkeydown = null;
|
||||
}
|
||||
|
||||
function chartPreciseTime(timestamp) {
|
||||
return new Intl.DateTimeFormat(locale(), {
|
||||
year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit'
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function nearestPoint(points, targetTs) {
|
||||
if (!points.length) return null;
|
||||
let lo=0, hi=points.length-1;
|
||||
while(lo<hi){
|
||||
const mid=Math.floor((lo+hi)/2);
|
||||
if(points[mid].ts < targetTs) lo=mid+1; else hi=mid;
|
||||
let lo = 0, hi = points.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (points[mid].ts < targetTs) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
const after=points[lo];
|
||||
const before=lo>0?points[lo-1]:null;
|
||||
if(!before) return after;
|
||||
return Math.abs(before.ts-targetTs) <= Math.abs(after.ts-targetTs) ? before : after;
|
||||
const after = points[lo];
|
||||
const before = lo > 0 ? points[lo - 1] : null;
|
||||
if (!before) return after;
|
||||
return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after;
|
||||
}
|
||||
|
||||
function formatChartTooltipValue(item, point, binaryLabels) {
|
||||
if (!point || !Number.isFinite(point.value)) return '—';
|
||||
if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row));
|
||||
const formatted = Number(point.value).toLocaleString(locale(), {minimumFractionDigits:1, maximumFractionDigits:2});
|
||||
const formatted = Number(point.value).toLocaleString(locale(), { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||
return binaryLabels ? formatted : `${formatted} °C`;
|
||||
}
|
||||
|
||||
function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
const wrap=canvas?.parentElement;
|
||||
const tooltip=wrap?.querySelector('.chart-tooltip');
|
||||
const line=wrap?.querySelector('.chart-hover-line');
|
||||
const selection=wrap?.querySelector('.chart-selection');
|
||||
if(!canvas || !wrap || !tooltip || !line) return;
|
||||
const wrap = canvas?.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
const selection = wrap?.querySelector('.chart-selection');
|
||||
if (!canvas || !wrap || !tooltip || !line) return;
|
||||
|
||||
const {pad,width,height,firstTs,lastTs,binaryLabels}=geometry;
|
||||
const plotWidth=width-pad.left-pad.right;
|
||||
const timestamps=[...new Set(sortedRows.map(row=>new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a,b)=>a-b);
|
||||
const timestampPoints=timestamps.map(ts=>({ts,value:ts}));
|
||||
const pointsBySeries=series.map(item=>({
|
||||
const { pad, width, height, firstTs, lastTs, binaryLabels } = geometry;
|
||||
const plotWidth = width - pad.left - pad.right;
|
||||
const timestamps = [...new Set(sortedRows.map(row => new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a, b) => a - b);
|
||||
const timestampPoints = timestamps.map(ts => ({ ts, value: ts }));
|
||||
const pointsBySeries = series.map(item => ({
|
||||
item,
|
||||
points:sortedRows.map(row=>({row,ts:new Date(row.timestamp).getTime(),value:item.value(row)}))
|
||||
.filter(point=>Number.isFinite(point.ts)&&Number.isFinite(point.value))
|
||||
.sort((a,b)=>a.ts-b.ts)
|
||||
points: sortedRows.map(row => ({ row, ts: new Date(row.timestamp).getTime(), value: item.value(row) }))
|
||||
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
}));
|
||||
const touches=new Map();
|
||||
let drag=null;
|
||||
let pinch=null;
|
||||
let touchPan=null;
|
||||
const touches = new Map();
|
||||
let drag = null;
|
||||
let pinch = null;
|
||||
let touchPan = null;
|
||||
|
||||
const hide=()=>{ tooltip.hidden=true; line.hidden=true; };
|
||||
const hideSelection=()=>{ if(selection) selection.hidden=true; };
|
||||
const canvasX=clientX=>{
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
return clamp(clientX-rect.left,pad.left,width-pad.right);
|
||||
const hide = () => { tooltip.hidden = true; line.hidden = true; };
|
||||
const hideSelection = () => { if (selection) selection.hidden = true; };
|
||||
const canvasX = clientX => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return clamp(clientX - rect.left, pad.left, width - pad.right);
|
||||
};
|
||||
const showAtTimestamp=(targetTs)=>{
|
||||
if(!timestamps.length) return hide();
|
||||
const snapped=nearestPoint(timestampPoints,targetTs)?.ts;
|
||||
if(!Number.isFinite(snapped)) return hide();
|
||||
const px=pad.left + (snapped-firstTs)/Math.max(1,lastTs-firstTs)*plotWidth;
|
||||
const values=pointsBySeries.map(({item,points})=>({item,point:nearestPoint(points,snapped)})).filter(entry=>entry.point);
|
||||
if(!values.length) return hide();
|
||||
const showAtTimestamp = (targetTs) => {
|
||||
if (!timestamps.length) return hide();
|
||||
const snapped = nearestPoint(timestampPoints, targetTs)?.ts;
|
||||
if (!Number.isFinite(snapped)) return hide();
|
||||
const px = pad.left + (snapped - firstTs) / Math.max(1, lastTs - firstTs) * plotWidth;
|
||||
const values = pointsBySeries.map(({ item, points }) => ({ item, point: nearestPoint(points, snapped) })).filter(entry => entry.point);
|
||||
if (!values.length) return hide();
|
||||
|
||||
$$('.chart-tooltip').forEach(node=>{ if(node!==tooltip) node.hidden=true; });
|
||||
$$('.chart-hover-line').forEach(node=>{ if(node!==line) node.hidden=true; });
|
||||
tooltip.innerHTML=`<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({item,point})=>{
|
||||
const sampleTime=Math.abs(point.ts-snapped)>1000?`<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>`:'';
|
||||
return `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(formatChartTooltipValue(item,point,binaryLabels))}${sampleTime}</span></div>`;
|
||||
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
|
||||
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
|
||||
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({ item, point }) => {
|
||||
const sampleTime = Math.abs(point.ts - snapped) > 1000 ? `<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>` : '';
|
||||
return `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(formatChartTooltipValue(item, point, binaryLabels))}${sampleTime}</span></div>`;
|
||||
}).join('')}</div>`;
|
||||
tooltip.hidden=false;
|
||||
line.hidden=false;
|
||||
line.style.left=`${px}px`;
|
||||
line.style.top=`${pad.top}px`;
|
||||
line.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`;
|
||||
tooltip.hidden = false;
|
||||
line.hidden = false;
|
||||
line.style.left = `${px}px`;
|
||||
line.style.top = `${pad.top}px`;
|
||||
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
|
||||
const minLeft=wrap.scrollLeft+8;
|
||||
const maxLeft=wrap.scrollLeft+wrap.clientWidth-tooltip.offsetWidth-8;
|
||||
let tooltipLeft=px+12;
|
||||
if(tooltipLeft+tooltip.offsetWidth>wrap.scrollLeft+wrap.clientWidth-8) tooltipLeft=px-tooltip.offsetWidth-12;
|
||||
tooltip.style.left=`${Math.max(minLeft,Math.min(tooltipLeft,Math.max(minLeft,maxLeft)))}px`;
|
||||
tooltip.style.top=`${pad.top+8}px`;
|
||||
canvas.dataset.chartHoverTs=String(snapped);
|
||||
const minLeft = wrap.scrollLeft + 8;
|
||||
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
|
||||
let tooltipLeft = px + 12;
|
||||
if (tooltipLeft + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) tooltipLeft = px - tooltip.offsetWidth - 12;
|
||||
tooltip.style.left = `${Math.max(minLeft, Math.min(tooltipLeft, Math.max(minLeft, maxLeft)))}px`;
|
||||
tooltip.style.top = `${pad.top + 8}px`;
|
||||
canvas.dataset.chartHoverTs = String(snapped);
|
||||
};
|
||||
|
||||
const showFromClientX=(clientX)=>{
|
||||
const localX=canvasX(clientX);
|
||||
const targetTs=firstTs + ((localX-pad.left)/Math.max(1,plotWidth))*(lastTs-firstTs);
|
||||
const showFromClientX = (clientX) => {
|
||||
const localX = canvasX(clientX);
|
||||
const targetTs = firstTs + ((localX - pad.left) / Math.max(1, plotWidth)) * (lastTs - firstTs);
|
||||
showAtTimestamp(targetTs);
|
||||
};
|
||||
|
||||
const updateSelection=(currentX)=>{
|
||||
if(!drag || !selection) return;
|
||||
const left=Math.min(drag.startX,currentX);
|
||||
const right=Math.max(drag.startX,currentX);
|
||||
drag.lastX=currentX;
|
||||
drag.moved=Math.abs(right-left)>=7;
|
||||
if(!drag.moved) return hideSelection();
|
||||
selection.hidden=false;
|
||||
selection.style.left=`${left}px`;
|
||||
selection.style.width=`${Math.max(1,right-left)}px`;
|
||||
selection.style.top=`${pad.top}px`;
|
||||
selection.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`;
|
||||
const updateSelection = (currentX) => {
|
||||
if (!drag || !selection) return;
|
||||
const left = Math.min(drag.startX, currentX);
|
||||
const right = Math.max(drag.startX, currentX);
|
||||
drag.lastX = currentX;
|
||||
drag.moved = Math.abs(right - left) >= 7;
|
||||
if (!drag.moved) return hideSelection();
|
||||
selection.hidden = false;
|
||||
selection.style.left = `${left}px`;
|
||||
selection.style.width = `${Math.max(1, right - left)}px`;
|
||||
selection.style.top = `${pad.top}px`;
|
||||
selection.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
};
|
||||
|
||||
canvas.onpointerdown=event=>{
|
||||
if(event.pointerType==='mouse'){
|
||||
if(event.button!==0) return;
|
||||
const startX=canvasX(event.clientX);
|
||||
drag={pointerId:event.pointerId,startX,lastX:startX,moved:false};
|
||||
canvas.onpointerdown = event => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
if (event.button !== 0) return;
|
||||
const startX = canvasX(event.clientX);
|
||||
drag = { pointerId: event.pointerId, startX, lastX: startX, moved: false };
|
||||
hide(); hideSelection();
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if(event.pointerType==='touch'){
|
||||
touches.set(event.pointerId,{x:event.clientX,y:event.clientY});
|
||||
if(touches.size===1){
|
||||
touchPan={pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,scrollLeft:wrap.scrollLeft,moved:false};
|
||||
if (event.pointerType === 'touch') {
|
||||
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (touches.size === 1) {
|
||||
touchPan = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, scrollLeft: wrap.scrollLeft, moved: false };
|
||||
showFromClientX(event.clientX);
|
||||
}
|
||||
if(touches.size===2){
|
||||
touchPan=null;
|
||||
const points=[...touches.values()];
|
||||
const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y);
|
||||
const midX=(points[0].x+points[1].x)/2;
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
const wrapRect=wrap.getBoundingClientRect();
|
||||
pinch={
|
||||
startDistance:Math.max(1,distance),
|
||||
startZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM),
|
||||
baseWidth:(rect.width||width)/clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM),
|
||||
anchorRatio:clamp((midX-rect.left)/Math.max(1,rect.width),0,1),
|
||||
viewportX:clamp(midX-wrapRect.left,0,wrap.clientWidth),
|
||||
previewZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM),
|
||||
if (touches.size === 2) {
|
||||
touchPan = null;
|
||||
const points = [...touches.values()];
|
||||
const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
|
||||
const midX = (points[0].x + points[1].x) / 2;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
pinch = {
|
||||
startDistance: Math.max(1, distance),
|
||||
startZoom: clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM),
|
||||
baseWidth: (rect.width || width) / clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM),
|
||||
anchorRatio: clamp((midX - rect.left) / Math.max(1, rect.width), 0, 1),
|
||||
viewportX: clamp(midX - wrapRect.left, 0, wrap.clientWidth),
|
||||
previewZoom: clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM),
|
||||
};
|
||||
hide();
|
||||
event.preventDefault();
|
||||
@@ -300,31 +300,31 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
}
|
||||
};
|
||||
|
||||
canvas.onpointermove=event=>{
|
||||
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){
|
||||
canvas.onpointermove = event => {
|
||||
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) {
|
||||
updateSelection(canvasX(event.clientX));
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if(event.pointerType==='touch' && touches.has(event.pointerId)){
|
||||
touches.set(event.pointerId,{x:event.clientX,y:event.clientY});
|
||||
if(pinch && touches.size>=2){
|
||||
const points=[...touches.values()].slice(0,2);
|
||||
const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y);
|
||||
pinch.previewZoom=clamp(pinch.startZoom*(distance/pinch.startDistance),1,MAX_CHART_ZOOM);
|
||||
const previewWidth=pinch.baseWidth*pinch.previewZoom;
|
||||
canvas.style.width=`${previewWidth}px`;
|
||||
wrap.scrollLeft=Math.max(0,pinch.anchorRatio*previewWidth-pinch.viewportX);
|
||||
const reset=canvas.closest('.history-chart-card')?.querySelector('[data-chart-zoom="reset"]');
|
||||
if(reset) reset.textContent=`${Math.round(pinch.previewZoom*100)}%`;
|
||||
if (event.pointerType === 'touch' && touches.has(event.pointerId)) {
|
||||
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinch && touches.size >= 2) {
|
||||
const points = [...touches.values()].slice(0, 2);
|
||||
const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
|
||||
pinch.previewZoom = clamp(pinch.startZoom * (distance / pinch.startDistance), 1, MAX_CHART_ZOOM);
|
||||
const previewWidth = pinch.baseWidth * pinch.previewZoom;
|
||||
canvas.style.width = `${previewWidth}px`;
|
||||
wrap.scrollLeft = Math.max(0, pinch.anchorRatio * previewWidth - pinch.viewportX);
|
||||
const reset = canvas.closest('.history-chart-card')?.querySelector('[data-chart-zoom="reset"]');
|
||||
if (reset) reset.textContent = `${Math.round(pinch.previewZoom * 100)}%`;
|
||||
event.preventDefault();
|
||||
} else if(touchPan?.pointerId===event.pointerId && touches.size===1){
|
||||
const dx=event.clientX-touchPan.startX;
|
||||
const dy=event.clientY-touchPan.startY;
|
||||
if(Math.abs(dx)>6 && Math.abs(dx)>Math.abs(dy)){
|
||||
touchPan.moved=true;
|
||||
} else if (touchPan?.pointerId === event.pointerId && touches.size === 1) {
|
||||
const dx = event.clientX - touchPan.startX;
|
||||
const dy = event.clientY - touchPan.startY;
|
||||
if (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy)) {
|
||||
touchPan.moved = true;
|
||||
hide();
|
||||
wrap.scrollLeft=Math.max(0,touchPan.scrollLeft-dx);
|
||||
wrap.scrollLeft = Math.max(0, touchPan.scrollLeft - dx);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -333,122 +333,122 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
showFromClientX(event.clientX);
|
||||
};
|
||||
|
||||
const finishPointer=event=>{
|
||||
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){
|
||||
const currentX=canvasX(event.clientX);
|
||||
const selectedWidth=Math.abs(currentX-drag.startX);
|
||||
const center=(currentX+drag.startX)/2;
|
||||
const moved=drag.moved && selectedWidth>=12;
|
||||
drag=null; hideSelection();
|
||||
try{ canvas.releasePointerCapture?.(event.pointerId); }catch(_){}
|
||||
if(moved){
|
||||
const currentZoom=clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM);
|
||||
const factor=Math.max(1,wrap.clientWidth/Math.max(1,selectedWidth));
|
||||
setChartZoom(canvas.id,currentZoom*factor,center/Math.max(1,width),wrap.clientWidth/2);
|
||||
}else showFromClientX(event.clientX);
|
||||
const finishPointer = event => {
|
||||
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) {
|
||||
const currentX = canvasX(event.clientX);
|
||||
const selectedWidth = Math.abs(currentX - drag.startX);
|
||||
const center = (currentX + drag.startX) / 2;
|
||||
const moved = drag.moved && selectedWidth >= 12;
|
||||
drag = null; hideSelection();
|
||||
try { canvas.releasePointerCapture?.(event.pointerId); } catch (_) { }
|
||||
if (moved) {
|
||||
const currentZoom = clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const factor = Math.max(1, wrap.clientWidth / Math.max(1, selectedWidth));
|
||||
setChartZoom(canvas.id, currentZoom * factor, center / Math.max(1, width), wrap.clientWidth / 2);
|
||||
} else showFromClientX(event.clientX);
|
||||
return;
|
||||
}
|
||||
if(event.pointerType==='touch'){
|
||||
const wasPan=touchPan?.pointerId===event.pointerId ? touchPan : null;
|
||||
if (event.pointerType === 'touch') {
|
||||
const wasPan = touchPan?.pointerId === event.pointerId ? touchPan : null;
|
||||
touches.delete(event.pointerId);
|
||||
if(pinch && touches.size<2){
|
||||
const completed=pinch;
|
||||
pinch=null;
|
||||
setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX);
|
||||
} else if(wasPan && !wasPan.moved) showFromClientX(event.clientX);
|
||||
if(touches.size===0) touchPan=null;
|
||||
if (pinch && touches.size < 2) {
|
||||
const completed = pinch;
|
||||
pinch = null;
|
||||
setChartZoom(canvas.id, completed.previewZoom, completed.anchorRatio, completed.viewportX);
|
||||
} else if (wasPan && !wasPan.moved) showFromClientX(event.clientX);
|
||||
if (touches.size === 0) touchPan = null;
|
||||
}
|
||||
};
|
||||
|
||||
canvas.onpointerup=finishPointer;
|
||||
canvas.onpointercancel=event=>{
|
||||
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ drag=null; hideSelection(); }
|
||||
if(event.pointerType==='touch'){
|
||||
canvas.onpointerup = finishPointer;
|
||||
canvas.onpointercancel = event => {
|
||||
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) { drag = null; hideSelection(); }
|
||||
if (event.pointerType === 'touch') {
|
||||
touches.delete(event.pointerId);
|
||||
if(touchPan?.pointerId===event.pointerId) touchPan=null;
|
||||
if(pinch && touches.size<2){
|
||||
const completed=pinch;
|
||||
pinch=null;
|
||||
setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX);
|
||||
if (touchPan?.pointerId === event.pointerId) touchPan = null;
|
||||
if (pinch && touches.size < 2) {
|
||||
const completed = pinch;
|
||||
pinch = null;
|
||||
setChartZoom(canvas.id, completed.previewZoom, completed.anchorRatio, completed.viewportX);
|
||||
} else hide();
|
||||
}
|
||||
};
|
||||
canvas.onpointerleave=event=>{ if(event.pointerType!=='touch' && !drag) hide(); };
|
||||
canvas.onkeydown=event=>{
|
||||
if(!['ArrowLeft','ArrowRight'].includes(event.key) || !timestamps.length) return;
|
||||
canvas.onpointerleave = event => { if (event.pointerType !== 'touch' && !drag) hide(); };
|
||||
canvas.onkeydown = event => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || !timestamps.length) return;
|
||||
event.preventDefault();
|
||||
const current=Number(canvas.dataset.chartHoverTs);
|
||||
let index=Number.isFinite(current)?timestamps.findIndex(ts=>ts===current):-1;
|
||||
if(index<0) index=event.key==='ArrowRight'?0:timestamps.length-1;
|
||||
else index=clamp(index+(event.key==='ArrowRight'?1:-1),0,timestamps.length-1);
|
||||
const current = Number(canvas.dataset.chartHoverTs);
|
||||
let index = Number.isFinite(current) ? timestamps.findIndex(ts => ts === current) : -1;
|
||||
if (index < 0) index = event.key === 'ArrowRight' ? 0 : timestamps.length - 1;
|
||||
else index = clamp(index + (event.key === 'ArrowRight' ? 1 : -1), 0, timestamps.length - 1);
|
||||
showAtTimestamp(timestamps[index]);
|
||||
};
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) {
|
||||
function drawLineChart(canvas, series, rows, { height = 340, minValue = null, maxValue = null, binaryLabels = false } = {}) {
|
||||
if (!canvas) return;
|
||||
const options={height,minValue,maxValue,binaryLabels};
|
||||
if(canvas.id) chartRuntime.set(canvas.id,{series,rows,options});
|
||||
const visibleSeries=series.filter((item,index)=>!isChartSeriesHidden(canvas.id,item,index));
|
||||
const options = { height, minValue, maxValue, binaryLabels };
|
||||
if (canvas.id) chartRuntime.set(canvas.id, { series, rows, options });
|
||||
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
|
||||
if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height);
|
||||
const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp));
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333');
|
||||
const pad={left:54,right:20,top:20,bottom:42};
|
||||
const allValues=[];
|
||||
visibleSeries.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas,height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue;
|
||||
if (max-min < 2) { min-=1; max+=1; }
|
||||
const sortedRows = [...rows].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
const { ctx, width } = prepareCanvas(canvas, height);
|
||||
const text = cssColor('--muted', '#888'), grid = cssColor('--grid', '#333');
|
||||
const pad = { left: 54, right: 20, top: 20, bottom: 42 };
|
||||
const allValues = [];
|
||||
visibleSeries.forEach(item => sortedRows.forEach(row => { const value = item.value(row); if (Number.isFinite(value)) allValues.push(value); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas, height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues) - 1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues) + 1) : maxValue;
|
||||
if (max - min < 2) { min -= 1; max += 1; }
|
||||
const firstTs = new Date(sortedRows[0].timestamp).getTime();
|
||||
const lastTs = new Date(sortedRows[sortedRows.length-1].timestamp).getTime();
|
||||
const span = Math.max(1,lastTs-firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right);
|
||||
const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom);
|
||||
ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1;
|
||||
for(let i=0;i<=5;i++){
|
||||
const value=min+(max-min)*i/5, py=y(value);
|
||||
ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke();
|
||||
ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3);
|
||||
const lastTs = new Date(sortedRows[sortedRows.length - 1].timestamp).getTime();
|
||||
const span = Math.max(1, lastTs - firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime() - firstTs) / span * (width - pad.left - pad.right);
|
||||
const y = value => pad.top + (max - value) / (max - min) * (height - pad.top - pad.bottom);
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const value = min + (max - min) * i / 5, py = y(value);
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, py); ctx.lineTo(width - pad.right, py); ctx.stroke();
|
||||
ctx.textAlign = 'right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`, pad.left - 8, py + 3);
|
||||
}
|
||||
const ticks=5;
|
||||
for(let i=0;i<=ticks;i++){
|
||||
const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]);
|
||||
ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14);
|
||||
const ticks = 5;
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
const idx = Math.min(sortedRows.length - 1, Math.round(i * (sortedRows.length - 1) / ticks)); const px = x(sortedRows[idx]);
|
||||
ctx.textAlign = 'center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp, $('#historyHours')?.value), px, height - 14);
|
||||
}
|
||||
visibleSeries.forEach(item => {
|
||||
ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]);
|
||||
let started=false, prev=null;
|
||||
ctx.beginPath(); ctx.strokeStyle = item.color; ctx.lineWidth = item.width || 2; ctx.setLineDash(item.dash || []);
|
||||
let started = false, prev = null;
|
||||
sortedRows.forEach(row => {
|
||||
const value=item.value(row); if(!Number.isFinite(value)) return;
|
||||
const px=x(row),py=y(value);
|
||||
if(!started||prev===null){ctx.moveTo(px,py);started=true;}
|
||||
else if(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
|
||||
prev=value;
|
||||
const value = item.value(row); if (!Number.isFinite(value)) return;
|
||||
const px = x(row), py = y(value);
|
||||
if (!started || prev === null) { ctx.moveTo(px, py); started = true; }
|
||||
else if (item.step) { ctx.lineTo(px, y(prev)); ctx.lineTo(px, py); } else ctx.lineTo(px, py);
|
||||
prev = value;
|
||||
});
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
});
|
||||
bindChartTooltip(canvas,visibleSeries,sortedRows,{pad,width,height,firstTs,lastTs,binaryLabels});
|
||||
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels });
|
||||
}
|
||||
|
||||
function renderLegend(host, series) {
|
||||
if (!host) return;
|
||||
const chartId=host.id.replace(/Legend$/,'');
|
||||
host.innerHTML=series.map((item,index)=>{
|
||||
const hidden=isChartSeriesHidden(chartId,item,index);
|
||||
const title=tr(hidden?'history.legendShow':'history.legendHide',{name:item.label});
|
||||
return `<button type="button" class="legend-item${hidden?' is-hidden':''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden?'false':'true'}" title="${esc(title)}"><i class="legend-line" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
|
||||
const chartId = host.id.replace(/Legend$/, '');
|
||||
host.innerHTML = series.map((item, index) => {
|
||||
const hidden = isChartSeriesHidden(chartId, item, index);
|
||||
const title = tr(hidden ? 'history.legendShow' : 'history.legendHide', { name: item.label });
|
||||
return `<button type="button" class="legend-item${hidden ? ' is-hidden' : ''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden ? 'false' : 'true'}" title="${esc(title)}"><i class="legend-line" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function historyChartMarkup(id, title, hint, compact=false) {
|
||||
function historyChartMarkup(id, title, hint, compact = false) {
|
||||
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const percent = Math.round(zoom * 100);
|
||||
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom<=1?'disabled':''}>−</button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom>=MAX_CHART_ZOOM?'disabled':''}>+</button></div></div><div class="chart-wrap ${compact?'compact-chart':''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact?300:420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
|
||||
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom <= 1 ? 'disabled' : ''}>−</button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom >= MAX_CHART_ZOOM ? 'disabled' : ''}>+</button></div></div><div class="chart-wrap ${compact ? 'compact-chart' : ''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact ? 300 : 420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
|
||||
}
|
||||
|
||||
function historySeriesColor(index) {
|
||||
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`);
|
||||
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index * 67) % 360} 68% 55%)`);
|
||||
}
|
||||
|
||||
|
||||
+35
-37
@@ -29,7 +29,7 @@ const app = {
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {},
|
||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||
@@ -40,7 +40,7 @@ try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_c
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB';
|
||||
const tr = (key, params = {}) => {
|
||||
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key;
|
||||
@@ -56,8 +56,8 @@ const zoneHysteresisForMode = (zone, mode) => {
|
||||
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);
|
||||
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
|
||||
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
|
||||
const fanLabel = value => ({ 0: 'fan.auto', 1: 'fan.low', 2: 'fan.mediumLow', 3: 'fan.medium', 4: 'fan.mediumHigh', 5: 'fan.high' }[value] ? tr({ 0: 'fan.auto', 1: 'fan.low', 2: 'fan.mediumLow', 3: 'fan.medium', 4: 'fan.mediumHigh', 5: 'fan.high' }[value]) : value);
|
||||
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
|
||||
const localResumeSeconds = value => {
|
||||
const timestamp = value ? new Date(value).getTime() : NaN;
|
||||
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null;
|
||||
@@ -67,7 +67,7 @@ const formatCountdown = seconds => {
|
||||
const safe = Math.max(0, Math.floor(seconds));
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const remainder = safe % 60;
|
||||
return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||
};
|
||||
const formatExtendedCountdown = seconds => {
|
||||
if (!Number.isFinite(seconds)) return '∞';
|
||||
@@ -76,15 +76,15 @@ const formatExtendedCountdown = seconds => {
|
||||
const minutes = Math.floor((safe % 3600) / 60);
|
||||
const remainder = safe % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`
|
||||
: `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
|
||||
? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`
|
||||
: `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||
};
|
||||
function updateLocalThermostatCountdowns() {
|
||||
$$('[data-local-resume-countdown]').forEach(node => {
|
||||
const remaining = localResumeSeconds(node.dataset.localResumeCountdown);
|
||||
const group = node.dataset.localResumeGroup || '';
|
||||
node.textContent = remaining !== null && remaining > 0
|
||||
? tr(group ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', {group, time:formatCountdown(remaining)})
|
||||
? tr(group ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group, time: formatCountdown(remaining) })
|
||||
: tr('zones.localThermostatResuming');
|
||||
});
|
||||
}
|
||||
@@ -103,23 +103,23 @@ function temporarySessionStatus(zone) {
|
||||
const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint);
|
||||
const targetText = Number.isFinite(target) ? target.toFixed(1) : '--';
|
||||
const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1);
|
||||
const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort';
|
||||
const condition = tr(operatorKey, {target:targetText, tolerance});
|
||||
const operatorKey = ({ within: 'zones.temporaryWithinShort', at_or_below: 'zones.temporaryAtOrBelowShort', at_or_above: 'zones.temporaryAtOrAboveShort' })[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort';
|
||||
const condition = tr(operatorKey, { target: targetText, tolerance });
|
||||
|
||||
if (session.state === 'waiting_master') {
|
||||
return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true};
|
||||
return { countdown: startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail: tr('zones.temporaryWaitingMaster'), kind: session.finish_kind, pending: true };
|
||||
}
|
||||
if (session.state === 'paused_manual') {
|
||||
return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending};
|
||||
return { countdown: '—', detail: tr('zones.temporaryPausedManual'), kind: session.finish_kind, pending };
|
||||
}
|
||||
if (pending) {
|
||||
return {
|
||||
countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00',
|
||||
detail:startRemaining != null && startRemaining > 0
|
||||
? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)})
|
||||
countdown: startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00',
|
||||
detail: startRemaining != null && startRemaining > 0
|
||||
? tr('zones.temporaryScheduledStatus', { time: dateTime(session.started_at) })
|
||||
: tr('zones.temporaryStartingStatus'),
|
||||
kind:session.finish_kind,
|
||||
pending:true,
|
||||
kind: session.finish_kind,
|
||||
pending: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,15 +129,15 @@ function temporarySessionStatus(zone) {
|
||||
if (Number.isFinite(started)) {
|
||||
const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000));
|
||||
const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining);
|
||||
return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind};
|
||||
return { countdown: formatExtendedCountdown(effectiveRemaining), detail: tr('zones.temporaryHoldingStatus', { condition }), kind: session.finish_kind };
|
||||
}
|
||||
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind};
|
||||
return { countdown: safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail: tr('zones.temporaryWaitingStable', { condition }), kind: session.finish_kind };
|
||||
}
|
||||
if (session.finish_kind === 'temperature_reached') {
|
||||
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind};
|
||||
return { countdown: safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail: tr('zones.temporaryWaitingReached', { condition }), kind: session.finish_kind };
|
||||
}
|
||||
const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus';
|
||||
return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind};
|
||||
return { countdown: formatExtendedCountdown(hardRemaining), detail: tr(detailKey), kind: session.finish_kind };
|
||||
}
|
||||
function updateTemporaryThermostatCountdowns() {
|
||||
$$('[data-temporary-countdown-zone]').forEach(node => {
|
||||
@@ -189,7 +189,7 @@ function updateConnectionIndicator(status) {
|
||||
node.title = label;
|
||||
}
|
||||
const banner = $('#connectionBanner');
|
||||
const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus);
|
||||
const disconnected = ['disconnected', 'connectionError'].includes(app.connectionStatus);
|
||||
document.body.classList.toggle('connection-lost', disconnected);
|
||||
if (banner) {
|
||||
banner.hidden = !disconnected;
|
||||
@@ -206,7 +206,7 @@ function updateToolbarControls() {
|
||||
if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase();
|
||||
const themeSelect = $('#themeSelect');
|
||||
const themeIcon = $('#themeIcon');
|
||||
const icons = {system:'◐',light:'☀',dark:'☾'};
|
||||
const icons = { system: '◐', light: '☀', dark: '☾' };
|
||||
if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system;
|
||||
if (themeSelect) {
|
||||
themeSelect.value = app.theme;
|
||||
@@ -263,29 +263,27 @@ function renderLanguageOptions() {
|
||||
|
||||
async function loadLanguages() {
|
||||
try {
|
||||
// Language packs must never come from a stale Service Worker/browser cache.
|
||||
// A cache-busting nonce is intentional: translations can change without the JS asset hash changing.
|
||||
const languageNonce = Date.now().toString(36);
|
||||
const languageUrl = path => {
|
||||
const separator = String(path).includes('?') ? '&' : '?';
|
||||
return `${withBase(path)}${separator}v=${languageNonce}`;
|
||||
};
|
||||
const response = await fetch(languageUrl('/lang/index.json'), {cache: 'no-store'});
|
||||
const response = await fetch(languageUrl('/lang/index.json'), { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
|
||||
const manifest = await response.json();
|
||||
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
|
||||
if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing');
|
||||
|
||||
const loaded = await Promise.all(languages.map(async item => {
|
||||
const packResponse = await fetch(languageUrl(item.path || `/lang/${encodeURIComponent(item.code)}.json`), {cache: 'no-store'});
|
||||
const packResponse = await fetch(languageUrl(item.path || `/lang/${encodeURIComponent(item.code)}.json`), { cache: 'no-store' });
|
||||
if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`);
|
||||
const pack = await packResponse.json();
|
||||
return {item, pack};
|
||||
return { item, pack };
|
||||
}));
|
||||
|
||||
app.languages = loaded.map(({item}) => item);
|
||||
app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}]));
|
||||
app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code]));
|
||||
app.languages = loaded.map(({ item }) => item);
|
||||
app.translations = Object.fromEntries(loaded.map(({ item, pack }) => [item.code, pack.translations || {}]));
|
||||
app.locales = Object.fromEntries(loaded.map(({ item, pack }) => [item.code, pack.meta?.locale || item.locale || item.code]));
|
||||
app.language = app.languages.some(item => item.code === preferredLanguage)
|
||||
? preferredLanguage
|
||||
: (manifest.default || DEFAULT_LANGUAGE);
|
||||
@@ -293,9 +291,9 @@ async function loadLanguages() {
|
||||
renderLanguageOptions();
|
||||
} catch (error) {
|
||||
console.error('Unable to load language packs:', error);
|
||||
app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}];
|
||||
app.translations = {[DEFAULT_LANGUAGE]: {}};
|
||||
app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'};
|
||||
app.languages = [{ code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB' }];
|
||||
app.translations = { [DEFAULT_LANGUAGE]: {} };
|
||||
app.locales = { [DEFAULT_LANGUAGE]: 'en-GB' };
|
||||
app.language = DEFAULT_LANGUAGE;
|
||||
renderLanguageOptions();
|
||||
}
|
||||
@@ -322,14 +320,14 @@ async function api(path, options = {}) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(withBase(path), {...options, headers, body});
|
||||
const response = await fetch(withBase(path), { ...options, headers, body });
|
||||
if (response.status === 401) {
|
||||
showTokenDialog();
|
||||
throw new Error(tr('auth.invalid'));
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = tr('error.http', {status: response.status});
|
||||
try { message = (await response.json()).error || message; } catch (_) {}
|
||||
let message = tr('error.http', { status: response.status });
|
||||
try { message = (await response.json()).error || message; } catch (_) { }
|
||||
throw new Error(message);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
|
||||
+57
-57
@@ -19,22 +19,22 @@ function renderAll() {
|
||||
|
||||
function renderSummary() {
|
||||
const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite);
|
||||
const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null;
|
||||
const average = temperatures.length ? temperatures.reduce((a, b) => a + b, 0) / temperatures.length : null;
|
||||
const online = app.devices.filter(d => d.online).length;
|
||||
const active = app.devices.filter(d => d.power).length;
|
||||
const demand = app.zones.filter(z => z.enabled && z.demand).length;
|
||||
const toolbarStatus = $('#toolbarRuntimeStatus');
|
||||
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', {active, total:app.devices.length});
|
||||
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', { active, total: app.devices.length });
|
||||
$('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}<small>°C</small>`;
|
||||
const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : '';
|
||||
const activeSuffix = active ? tr('dashboard.summaryActive', { count: active }) : '';
|
||||
$('#summaryText').textContent = app.devices.length
|
||||
? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix})
|
||||
? tr('dashboard.summary', { online, total: app.devices.length, active: activeSuffix })
|
||||
: tr('dashboard.empty');
|
||||
$('#metrics').innerHTML = [
|
||||
[tr('dashboard.metricOnline'), `${online}/${app.devices.length}`],
|
||||
[tr('dashboard.metricActive'), active],
|
||||
[tr('dashboard.metricDemand'), demand],
|
||||
].map(([label,value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
|
||||
].map(([label, value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
|
||||
renderSystemInfo();
|
||||
}
|
||||
|
||||
@@ -45,28 +45,28 @@ function renderHouseClimate() {
|
||||
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
|
||||
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
||||
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
|
||||
// Global power buttons are bulk thermostat actions, not a persistent master gate.
|
||||
|
||||
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
|
||||
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
|
||||
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
|
||||
<div class="house-mode-row">
|
||||
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool" aria-pressed="${mode==='cool'}">${esc(tr('mode.cool'))}</button>
|
||||
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat" aria-pressed="${mode==='heat'}">${esc(tr('mode.heat'))}</button>
|
||||
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off" aria-pressed="${mode==='off'}">${esc(tr('house.noControl'))}</button>
|
||||
<button class="${mode === 'cool' ? 'active' : ''}" data-action="house-mode" data-value="cool" aria-pressed="${mode === 'cool'}">${esc(tr('mode.cool'))}</button>
|
||||
<button class="${mode === 'heat' ? 'active' : ''}" data-action="house-mode" data-value="heat" aria-pressed="${mode === 'heat'}">${esc(tr('mode.heat'))}</button>
|
||||
<button class="${mode === 'off' ? 'active' : ''}" data-action="house-mode" data-value="off" aria-pressed="${mode === 'off'}">${esc(tr('house.noControl'))}</button>
|
||||
</div>
|
||||
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button class="${preset===p?'active':''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset===p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
|
||||
<div class="preset-row house-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(p => `<button class="${preset === p ? 'active' : ''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset === p}">${esc(p === 'sleep' ? tr('house.sleepAll') : p === 'comfort' ? tr('house.comfortAll') : p === 'away' ? tr('house.awayAll') : tr('house.autoAll'))}</button>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function planEventMarkup(event) {
|
||||
const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—';
|
||||
const when = event?.at ? new Date(event.at).toLocaleString(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '—';
|
||||
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
|
||||
return `<li><time>${esc(when)}</time><span>${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}</span></li>`;
|
||||
}
|
||||
|
||||
function automationTriggerLabel(item) {
|
||||
if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'});
|
||||
if (item.trigger_kind === 'time') return tr('automations.triggerAt', { time: item.at_time || '—' });
|
||||
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
|
||||
return tr(key, {temperature: fmtTemp(item.threshold)});
|
||||
return tr(key, { temperature: fmtTemp(item.threshold) });
|
||||
}
|
||||
|
||||
function planZoneEffectiveEnabled(zone) {
|
||||
@@ -84,7 +84,7 @@ function renderControlPlan() {
|
||||
|
||||
const allZones = plan.zones || [];
|
||||
const houseEvents = (plan.next_events || []).slice(0, 3);
|
||||
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
|
||||
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
|
||||
|
||||
const groupCards = (app.groups || []).map(group => {
|
||||
const memberIds = new Set(group.zone_ids || []);
|
||||
@@ -94,9 +94,9 @@ function renderControlPlan() {
|
||||
const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
|
||||
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
|
||||
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
|
||||
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`})))
|
||||
.sort((a,b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
|
||||
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', {zones:members.length, demand}))}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}</li>`}</ul></article>`;
|
||||
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({ ...event, label: `${zone.zone_name}: ${event.label}` })))
|
||||
.sort((a, b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
|
||||
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}</li>`}</ul></article>`;
|
||||
}).join('');
|
||||
|
||||
const zoneGroups = new Map();
|
||||
@@ -115,7 +115,7 @@ function renderControlPlan() {
|
||||
}).join('');
|
||||
|
||||
const rules = (plan.rules || []).filter(rule => rule.enabled);
|
||||
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', {count:rules.length}))}</h3></div></div><ul class="plan-events">${rules.slice(0,3).map(rule=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)} → ${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}</span></li>`).join('')}</ul></article>` : '';
|
||||
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => `<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)} → ${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}</span></li>`).join('')}</ul></article>` : '';
|
||||
host.innerHTML = house + groupCards + zones + ruleCard;
|
||||
}
|
||||
async function loadControlPlan() {
|
||||
@@ -126,7 +126,7 @@ async function loadControlPlan() {
|
||||
function simulationTime(value, options = {}) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', '');
|
||||
return date.toLocaleString(locale(), { weekday: options.withDay === false ? undefined : 'short', hour: '2-digit', minute: '2-digit' }).replace(',', '');
|
||||
}
|
||||
|
||||
function simulationNumeric(value) {
|
||||
@@ -160,13 +160,13 @@ function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
|
||||
|
||||
function simulationStatusInfo(status) {
|
||||
const map = {
|
||||
disabled: {label: tr('common.disabled'), badge: ''},
|
||||
off: {label: tr('house.noControl'), badge: ''},
|
||||
waiting: {label: tr('simulation.waitingForData'), badge: ''},
|
||||
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
|
||||
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
|
||||
disabled: { label: tr('common.disabled'), badge: '' },
|
||||
off: { label: tr('house.noControl'), badge: '' },
|
||||
waiting: { label: tr('simulation.waitingForData'), badge: '' },
|
||||
demand: { label: tr('simulation.stateDemand'), badge: 'active' },
|
||||
satisfied: { label: tr('simulation.stateSatisfied'), badge: '' },
|
||||
};
|
||||
return map[status] || {label: status, badge: ''};
|
||||
return map[status] || { label: status, badge: '' };
|
||||
}
|
||||
|
||||
function buildZoneSimulation(zone, planZone) {
|
||||
@@ -222,9 +222,9 @@ function buildZoneSimulation(zone, planZone) {
|
||||
: current == null || target == null
|
||||
? tr('simulation.reasonWaiting')
|
||||
: demand
|
||||
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
|
||||
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
|
||||
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning};
|
||||
? tr('simulation.reasonDemand', { mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1) })
|
||||
: tr('simulation.reasonSatisfied', { target: target.toFixed(1), standby: standbyTarget.toFixed(1) });
|
||||
return { device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning };
|
||||
}
|
||||
|
||||
function simulationRuleAction(rule) {
|
||||
@@ -244,12 +244,12 @@ function renderSimulationScopeControls(plan) {
|
||||
const scope = $('#simulationScope');
|
||||
const target = $('#simulationTarget');
|
||||
if (!scope || !target) return;
|
||||
if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units';
|
||||
if (!['units', 'groups'].includes(app.simulationScope)) app.simulationScope = 'units';
|
||||
scope.value = app.simulationScope;
|
||||
const zones = plan?.zones || [];
|
||||
const options = app.simulationScope === 'groups'
|
||||
? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))]
|
||||
: [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))];
|
||||
? [{ id: 'all', name: tr('simulation.allGroups') }, ...app.groups.map(group => ({ id: group.id, name: group.name }))]
|
||||
: [{ id: 'all', name: tr('simulation.allUnits') }, ...zones.map(zone => ({ id: zone.zone_id, name: `${zone.zone_name} · ${zone.device_name || tr('common.device')}` }))];
|
||||
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
|
||||
target.innerHTML = options.map(option => `<option value="${esc(option.id)}">${esc(option.name)}</option>`).join('');
|
||||
target.value = app.simulationTarget;
|
||||
@@ -317,27 +317,27 @@ function renderSimulationPage() {
|
||||
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||
const nightOn = !!plan.night_mode_active;
|
||||
summaryHost.innerHTML = [
|
||||
{label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')})},
|
||||
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
|
||||
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
|
||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`},
|
||||
{ label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }) },
|
||||
{ label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}` },
|
||||
{ label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', { time: dateTime(plan.generated_at) }) },
|
||||
{ label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', { count: demandingZones.length })} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}` },
|
||||
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
||||
|
||||
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
|
||||
const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085};
|
||||
const x = { sensor: 45, thermostat: 305, decision: 565, unit: 825, event: 1085 };
|
||||
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const pathBetween = (ax, ay, bx, by, cls='') => {
|
||||
const pathBetween = (ax, ay, bx, by, cls = '') => {
|
||||
const bend = Math.max(36, (bx - ax) * .45);
|
||||
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax+bend} ${ay}, ${bx-bend} ${by}, ${bx} ${by}"/>`;
|
||||
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax + bend} ${ay}, ${bx - bend} ${by}, ${bx} ${by}"/>`;
|
||||
};
|
||||
const verticalLink = (ax, ay, bx, by, cls='bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay+35}, ${bx} ${by-35}, ${bx} ${by}"/>`;
|
||||
const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`;
|
||||
const verticalLink = (ax, ay, bx, by, cls = 'bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay + 35}, ${bx} ${by - 35}, ${bx} ${by}"/>`;
|
||||
const node = ({ left, top, kind, eyebrow, title, value, meta = '', badge = '', badgeClass = '' }) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`;
|
||||
|
||||
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
|
||||
nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')}),badge:tr('simulation.house')}));
|
||||
nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''}));
|
||||
nodes.push(node({ left: globalHouseLeft, top: globalTop, kind: 'logic global', eyebrow: tr('simulation.globalInput'), title: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), meta: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }), badge: tr('simulation.house') }));
|
||||
nodes.push(node({ left: globalNightLeft, top: globalTop, kind: `logic global ${nightOn ? 'night-active' : ''}`, eyebrow: tr('simulation.globalInput'), title: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), meta: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`, badge: nightOn ? '☾' : '○', badgeClass: nightOn ? 'active' : '' }));
|
||||
|
||||
zones.forEach((planZone, index) => {
|
||||
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
||||
@@ -356,20 +356,20 @@ function renderSimulationPage() {
|
||||
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
|
||||
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
|
||||
|
||||
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</span></div>`);
|
||||
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
|
||||
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)}));
|
||||
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
|
||||
nodes.push(`<div class="flow-lane-label" style="top:${y - 27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</span></div>`);
|
||||
nodes.push(node({ left: x.sensor, top: y, kind: 'input', eyebrow: tr('simulation.stepRoom'), title: sensorName, value: fmtTemp(sim.current), meta: source, badge: '●', badgeClass: sim.current == null ? '' : 'active' }));
|
||||
nodes.push(node({ left: x.thermostat, top: y, kind: 'logic', eyebrow: tr('simulation.thermostat'), title: planZone.zone_name, value: sim.target == null ? '—' : fmtTemp(sim.target), meta: `${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis', { value: sim.hysteresis.toFixed(1) })}`, badge: houseModeLabel(sim.mode) }));
|
||||
nodes.push(node({ left: x.decision, top: y, kind: `logic decision ${sim.demand ? 'demand' : 'satisfied'}`, eyebrow: tr('simulation.stepDecision'), title: status.label, value: sim.demand ? tr('simulation.callForComfort') : tr('simulation.standby'), meta: sim.reasoning, badge: sim.demand ? '▶' : '✓', badgeClass: status.badge }));
|
||||
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
|
||||
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?tr('simulation.running'):tr('simulation.idle')),badgeClass:sim.demand?'active':''}));
|
||||
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'}));
|
||||
nodes.push(node({ left: x.unit, top: y, kind: 'action', eyebrow: tr('simulation.stepCommand'), title: planZone.device_name || tr('common.device'), value: command, meta: `${tr('devices.quiet')}: ${quietText}${sleepMeta}`, badge: sim.mode === 'off' ? tr('simulation.manual') : (sim.demand ? tr('simulation.running') : tr('simulation.idle')), badgeClass: sim.demand ? 'active' : '' }));
|
||||
nodes.push(node({ left: x.event, top: y, kind: 'event', eyebrow: tr('simulation.nextEvent'), title: eventValue, value: next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'), meta: eventMeta, badge: '›' }));
|
||||
|
||||
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
|
||||
links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link'));
|
||||
links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link'));
|
||||
links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link'));
|
||||
links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus'));
|
||||
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus'));
|
||||
links.push(pathBetween(x.sensor + nodeW, mid, x.thermostat, mid, 'input-link'));
|
||||
links.push(pathBetween(x.thermostat + nodeW, mid, x.decision, mid, 'logic-link'));
|
||||
links.push(pathBetween(x.decision + nodeW, mid, x.unit, mid, sim.demand ? 'active-link' : 'logic-link'));
|
||||
links.push(pathBetween(x.unit + nodeW, mid, x.event, mid, 'action-link'));
|
||||
links.push(verticalLink(globalHouseLeft + nodeW / 2, globalTop + nodeH, x.thermostat + nodeW / 2, y, 'bus'));
|
||||
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft + nodeW / 2, globalTop + nodeH, x.decision + nodeW / 2, y, nightOn ? 'night-link' : 'bus'));
|
||||
});
|
||||
|
||||
boardHost.style.width = `${boardW}px`;
|
||||
@@ -377,8 +377,8 @@ function renderSimulationPage() {
|
||||
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
|
||||
|
||||
const timelineItems = [];
|
||||
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
||||
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
||||
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({ scope: tr('simulation.house'), event }));
|
||||
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({ scope: zone.zone_name, event, device: zone.device_name })));
|
||||
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||
const unique = [], seen = new Set();
|
||||
for (const item of timelineItems) {
|
||||
@@ -391,7 +391,7 @@ function renderSimulationPage() {
|
||||
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
||||
|
||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
}
|
||||
|
||||
function scheduleControlPlanLoad() {
|
||||
|
||||
+74
-76
@@ -7,7 +7,7 @@ function deviceFeaturePanel(device) {
|
||||
['sleep', 'supports_sleep', tr('devices.sleep')],
|
||||
].filter(([, support]) => device[support] === true);
|
||||
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
|
||||
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field,,label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
|
||||
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field, , label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
|
||||
}
|
||||
|
||||
function deviceZoneDisabled(deviceId) {
|
||||
@@ -19,8 +19,8 @@ function zoneForDevice(deviceId) {
|
||||
}
|
||||
|
||||
function deviceCard(device, detailed = false) {
|
||||
const modes = ['auto','cool','dry','fan','heat'];
|
||||
const fans = [0,1,3,5];
|
||||
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
|
||||
const fans = [0, 1, 3, 5];
|
||||
const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
|
||||
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
|
||||
const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
|
||||
@@ -86,8 +86,8 @@ function zonePresetLabel(preset) {
|
||||
}
|
||||
|
||||
function groupControlHue(groupId) {
|
||||
let hash=0;
|
||||
for(const char of String(groupId || 'group')) hash=((hash*31)+char.charCodeAt(0))>>>0;
|
||||
let hash = 0;
|
||||
for (const char of String(groupId || 'group')) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
|
||||
return (hash % 300) + 20;
|
||||
}
|
||||
|
||||
@@ -96,42 +96,42 @@ function groupControlStyle(group) {
|
||||
}
|
||||
|
||||
function groupControlForZone(zone) {
|
||||
if(zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
|
||||
const source=String(zone?.control_source || '');
|
||||
if(!source.startsWith('group:')) return null;
|
||||
const groupId=source.slice(6);
|
||||
return app.groups.find(group => group.id===groupId && group.power_enabled !== false && (group.zone_ids || []).includes(zone.id)) || null;
|
||||
if (zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
|
||||
const source = String(zone?.control_source || '');
|
||||
if (!source.startsWith('group:')) return null;
|
||||
const groupId = source.slice(6);
|
||||
return app.groups.find(group => group.id === groupId && group.power_enabled !== false && (group.zone_ids || []).includes(zone.id)) || null;
|
||||
}
|
||||
|
||||
function zoneLockoutActive(zone) {
|
||||
const until=zone?.lockout_until ? new Date(zone.lockout_until).getTime() : NaN;
|
||||
return Number.isFinite(until) && until>Date.now();
|
||||
const until = zone?.lockout_until ? new Date(zone.lockout_until).getTime() : NaN;
|
||||
return Number.isFinite(until) && until > Date.now();
|
||||
}
|
||||
|
||||
function compressorPendingDescription(zone) {
|
||||
const action=String(zone?.compressor_pending_action || '');
|
||||
if(!action) return '';
|
||||
const action = String(zone?.compressor_pending_action || '');
|
||||
if (!action) return '';
|
||||
const [kind, mode, target] = action.split(':');
|
||||
const targetValue = Number(target);
|
||||
if(kind==='mode_change') return tr('zones.queuedModeChange', {mode:modeLabel(mode), temperature:Number.isFinite(targetValue)?targetValue.toFixed(1):'—'});
|
||||
if(kind==='power_on') return tr('zones.queuedPowerOn', {mode:modeLabel(mode), temperature:Number.isFinite(targetValue)?targetValue.toFixed(1):'—'});
|
||||
if(kind==='global_power_on') return tr('zones.queuedGlobalPowerOn');
|
||||
if (kind === 'mode_change') return tr('zones.queuedModeChange', { mode: modeLabel(mode), temperature: Number.isFinite(targetValue) ? targetValue.toFixed(1) : '—' });
|
||||
if (kind === 'power_on') return tr('zones.queuedPowerOn', { mode: modeLabel(mode), temperature: Number.isFinite(targetValue) ? targetValue.toFixed(1) : '—' });
|
||||
if (kind === 'global_power_on') return tr('zones.queuedGlobalPowerOn');
|
||||
return tr('zones.queuedAction');
|
||||
}
|
||||
|
||||
function compressorQueuePanel(zone) {
|
||||
if(!zone?.compressor_pending_action) return '';
|
||||
const until=zone.compressor_pending_until || zone.lockout_until;
|
||||
const untilMs=until ? new Date(until).getTime() : NaN;
|
||||
const when=Number.isFinite(untilMs) && untilMs>Date.now() ? dateTime(until) : tr('zones.afterProtection');
|
||||
return `<div class="compressor-queue-panel"><div><strong>${esc(tr('zones.compressorQueueTitle'))}</strong><span>${esc(compressorPendingDescription(zone))}</span><small>${esc(tr('zones.compressorQueueUntil', {time:when}))}</small></div><button type="button" class="secondary" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button></div>`;
|
||||
if (!zone?.compressor_pending_action) return '';
|
||||
const until = zone.compressor_pending_until || zone.lockout_until;
|
||||
const untilMs = until ? new Date(until).getTime() : NaN;
|
||||
const when = Number.isFinite(untilMs) && untilMs > Date.now() ? dateTime(until) : tr('zones.afterProtection');
|
||||
return `<div class="compressor-queue-panel"><div><strong>${esc(tr('zones.compressorQueueTitle'))}</strong><span>${esc(compressorPendingDescription(zone))}</span><small>${esc(tr('zones.compressorQueueUntil', { time: when }))}</small></div><button type="button" class="secondary" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button></div>`;
|
||||
}
|
||||
|
||||
function compressorQueueRemainingText(until) {
|
||||
const timestamp = until ? new Date(until).getTime() : NaN;
|
||||
if (!Number.isFinite(timestamp)) return tr('zones.afterProtection');
|
||||
const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));
|
||||
return seconds > 0 ? tr('zones.queueRemaining', {time:formatExtendedCountdown(seconds)}) : tr('zones.queueReady');
|
||||
return seconds > 0 ? tr('zones.queueRemaining', { time: formatExtendedCountdown(seconds) }) : tr('zones.queueReady');
|
||||
}
|
||||
|
||||
function renderCompressorQueueModal() {
|
||||
@@ -142,7 +142,7 @@ function renderCompressorQueueModal() {
|
||||
const pending = app.zones
|
||||
.filter(zone => !!zone.compressor_pending_action)
|
||||
.slice()
|
||||
.sort((a,b) => {
|
||||
.sort((a, b) => {
|
||||
const at = new Date(a.compressor_pending_until || 0).getTime();
|
||||
const bt = new Date(b.compressor_pending_until || 0).getTime();
|
||||
return (Number.isFinite(at) ? at : Number.MAX_SAFE_INTEGER) - (Number.isFinite(bt) ? bt : Number.MAX_SAFE_INTEGER);
|
||||
@@ -155,7 +155,7 @@ function renderCompressorQueueModal() {
|
||||
list.innerHTML = `<div class="compressor-queue-empty"><span aria-hidden="true">✓</span><strong>${esc(tr('zones.queueEmptyTitle'))}</strong><p>${esc(tr('zones.queueEmptyText'))}</p></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = pending.map((zone,index) => {
|
||||
list.innerHTML = pending.map((zone, index) => {
|
||||
const device = app.devices.find(item => item.id === zone.device_id);
|
||||
const until = zone.compressor_pending_until || zone.lockout_until;
|
||||
const group = groupControlForZone(zone);
|
||||
@@ -169,8 +169,8 @@ function renderCompressorQueueModal() {
|
||||
<div class="compressor-queue-item-head"><div><span class="eyebrow">${esc(zone.name)}</span><h3>${esc(device?.name || tr('common.noDevice'))}</h3></div><span class="compressor-queue-reason">${esc(tr('zones.queueReasonProtection'))}</span></div>
|
||||
<p class="compressor-queue-command">${esc(compressorPendingDescription(zone))}</p>
|
||||
<div class="compressor-queue-meta">
|
||||
<span>${esc(tr('zones.queueSource', {source}))}</span>
|
||||
<span>${esc(tr('zones.compressorQueueUntil', {time:exact}))}</span>
|
||||
<span>${esc(tr('zones.queueSource', { source }))}</span>
|
||||
<span>${esc(tr('zones.compressorQueueUntil', { time: exact }))}</span>
|
||||
<strong data-compressor-queue-countdown="${esc(until || '')}">${esc(compressorQueueRemainingText(until))}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,26 +192,26 @@ function openCompressorQueueDialog() {
|
||||
}
|
||||
|
||||
function zoneControlOwnerLabel(zone) {
|
||||
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
|
||||
const source=zone.control_source || '';
|
||||
if(owner==='global_off') return tr('zones.ownerGlobalOff');
|
||||
if(owner==='direct_manual') {
|
||||
if(source==='home_assistant_direct') return tr('zones.ownerHaDirect');
|
||||
if(source==='web_direct') return tr('zones.ownerWebDirect');
|
||||
const owner = zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
|
||||
const source = zone.control_source || '';
|
||||
if (owner === 'global_off') return tr('zones.ownerGlobalOff');
|
||||
if (owner === 'direct_manual') {
|
||||
if (source === 'home_assistant_direct') return tr('zones.ownerHaDirect');
|
||||
if (source === 'web_direct') return tr('zones.ownerWebDirect');
|
||||
return tr('zones.ownerExternal');
|
||||
}
|
||||
if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat');
|
||||
if(source.startsWith('group:')) return tr('zones.ownerGroup');
|
||||
if (owner === 'local_thermostat') return tr('zones.ownerLocalThermostat');
|
||||
if (source.startsWith('group:')) return tr('zones.ownerGroup');
|
||||
return tr('zones.ownerAutomation');
|
||||
}
|
||||
|
||||
function zoneControlOwnerMeta(zone) {
|
||||
const parts=[];
|
||||
const controlGroup=groupControlForZone(zone);
|
||||
if(controlGroup) parts.push(`${tr('groups.group')}: ${controlGroup.name}`);
|
||||
if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
|
||||
if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
|
||||
if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
|
||||
const parts = [];
|
||||
const controlGroup = groupControlForZone(zone);
|
||||
if (controlGroup) parts.push(`${tr('groups.group')}: ${controlGroup.name}`);
|
||||
if (zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
|
||||
if (zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
|
||||
if (zone.lockout_until && new Date(zone.lockout_until) > new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -224,9 +224,9 @@ function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) {
|
||||
if (device && (!device.online || Number(device.communication_failures || 0) > 0)) return tr('zones.waitingOffline');
|
||||
if (zone.current_temperature == null) return tr('zones.noMeasurement');
|
||||
if (zone.compressor_pending_action && zoneLockoutActive(zone)) return tr('zones.waitingLockout');
|
||||
if (zone.compressor_cancelled_action && device && (!device.power || (['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode))) return tr('zones.queueCancelledStatus');
|
||||
if (zone.compressor_cancelled_action && device && (!device.power || (['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode))) return tr('zones.queueCancelledStatus');
|
||||
if (zone.demand && device && !device.power) return tr('zones.waitingStart');
|
||||
if (zone.demand && device?.power && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode) return tr('zones.waitingMode');
|
||||
if (zone.demand && device?.power && ['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode) return tr('zones.waitingMode');
|
||||
return zone.demand ? tr('zones.runningDemand') : tr('zones.satisfied');
|
||||
}
|
||||
|
||||
@@ -241,8 +241,6 @@ function zoneCard(zone, detailed = true) {
|
||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
|
||||
const idleWithoutTarget = effectiveMode === 'off' && zone.effective_setpoint == null && zone.manual_setpoint == null && zone.manual_preset == null;
|
||||
// OFF means "no active demand", not "forget the thermostat setting". Keep the last
|
||||
// backend-persisted setpoint visible so Group/Global OFF does not blank the temperature UI.
|
||||
const target = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint);
|
||||
const displayPreset = idleWithoutTarget ? 'auto' : (zone.manual_preset || zone.active_preset || 'comfort');
|
||||
const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode);
|
||||
@@ -253,7 +251,7 @@ function zoneCard(zone, detailed = true) {
|
||||
const deviceUnavailable = !!device && (device.enabled === false || !device.online || Number(device.communication_failures || 0) > 0);
|
||||
const lockoutWaiting = !!zone.compressor_pending_action && zoneLockoutActive(zone);
|
||||
const startWaiting = zone.demand && !!device && !deviceUnavailable && !device.power;
|
||||
const modeWaiting = zone.demand && !!device?.power && !deviceUnavailable && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode;
|
||||
const modeWaiting = zone.demand && !!device?.power && !deviceUnavailable && ['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode;
|
||||
const visualGroup = controlGroup;
|
||||
const waitingDemand = lockoutWaiting || startWaiting || modeWaiting || (zone.demand && deviceUnavailable);
|
||||
const workingDemand = zone.demand && !!device?.power && !deviceUnavailable && !waitingDemand;
|
||||
@@ -267,28 +265,28 @@ function zoneCard(zone, detailed = true) {
|
||||
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
|
||||
const override = deviceManualOverride
|
||||
? (zone.device_manual_override_until
|
||||
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
|
||||
: tr('zones.manualDeviceNoBoundary'))
|
||||
? tr('zones.manualDeviceUntil', { time: new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) })
|
||||
: tr('zones.manualDeviceNoBoundary'))
|
||||
: temporarySession
|
||||
? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'})
|
||||
: localThermostatOverride
|
||||
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
|
||||
: (zone.manual_override_until
|
||||
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
|
||||
? tr('zones.temporaryOverride', { time: temporaryStatus?.countdown || '—' })
|
||||
: localThermostatOverride
|
||||
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
|
||||
: (zone.manual_override_until
|
||||
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' })}`
|
||||
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
|
||||
const manualTakeover = deviceManualOverride
|
||||
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
||||
: localThermostatOverride && !temporarySession
|
||||
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
|
||||
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}"${pausedGroup ? ` data-local-resume-group="${esc(pausedGroup.name)}"` : ''}>${esc(tr(pausedGroup ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', {group:pausedGroup?.name || '', time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}</p>`
|
||||
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
|
||||
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}"${pausedGroup ? ` data-local-resume-group="${esc(pausedGroup.name)}"` : ''}>${esc(tr(pausedGroup ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group: pausedGroup?.name || '', time: formatCountdown(localResumeSeconds(localThermostatResumeAt)) }))}</p>`
|
||||
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
|
||||
: '';
|
||||
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
|
||||
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
|
||||
const localThermostatPowerControl = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">⏻</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
|
||||
const temporaryThermostatControl = temporarySession
|
||||
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
|
||||
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><span aria-hidden="true">⏱</span><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
|
||||
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
|
||||
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
|
||||
|
||||
if (detailed) {
|
||||
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
|
||||
@@ -316,11 +314,11 @@ function zoneCard(zone, detailed = true) {
|
||||
const runtimeStatus = zoneRuntimeStatusLabel(zone, effectiveMode, device);
|
||||
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-card="${esc(zone.id)}"${groupControlStyle(visualGroup)}>
|
||||
<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 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="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('')}
|
||||
${['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>
|
||||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode === 'house' && globalModeAvailable ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="zone-state-line"><span class="zone-runtime-status" title="${esc(runtimeStatus)}">${esc(runtimeStatus)}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
|
||||
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
|
||||
${compressorQueuePanel(zone)}
|
||||
@@ -388,10 +386,10 @@ function groupCard(group, detailed = false) {
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div>${groupStatusBadge}</div>
|
||||
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
|
||||
<div class="group-control-block"><small>${esc(tr('groups.controlToggle'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}"${climateControlsDisabled}>${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}"${climateControlsDisabled}>${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}"${climateControlsDisabled}>${esc(tr('preset.custom'))}</button></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house', 'heat', 'cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}"${climateControlsDisabled}>${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}"${climateControlsDisabled}>${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}"${climateControlsDisabled}>${esc(tr('preset.custom'))}</button></div>
|
||||
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
|
||||
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', { count: state.zones.length }))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@@ -408,7 +406,7 @@ function renderGroups() {
|
||||
async function sendGroupControl(id, patch) {
|
||||
try {
|
||||
await enqueueClimateControlTask(async () => {
|
||||
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||||
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch });
|
||||
if (result.group) {
|
||||
const index = app.groups.findIndex(group => group.id === result.group.id);
|
||||
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
|
||||
@@ -422,7 +420,7 @@ async function sendGroupControl(id, patch) {
|
||||
if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id];
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('groups.partial', {count: failed}), true);
|
||||
if (failed) toast(tr('groups.partial', { count: failed }), true);
|
||||
else toast(tr('groups.controlUpdated'));
|
||||
return result;
|
||||
});
|
||||
@@ -451,10 +449,10 @@ function populateGroup(id) {
|
||||
}
|
||||
|
||||
function renderSchedules() {
|
||||
const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`));
|
||||
const dayNames = Array.from({ length: 7 }, (_, index) => tr(`day.${index + 1}`));
|
||||
$('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => {
|
||||
const zone = app.zones.find(z => z.id === item.zone_id);
|
||||
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
|
||||
const days = item.weekdays.map(day => dayNames[day - 1]).join(', ');
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
@@ -468,8 +466,8 @@ function renderAutomations() {
|
||||
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
||||
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
||||
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : '—'}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
|
||||
}
|
||||
@@ -488,13 +486,13 @@ function fillSelects() {
|
||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||
const presetZone = $('#schedulePresetZone');
|
||||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
||||
const copyZone=$('#copyZoneSource');
|
||||
if(copyZone){
|
||||
const current=copyZone.value;
|
||||
const editingZoneId=$('#zoneForm')?.dataset.editingZoneId || '';
|
||||
const sources=app.zones.filter(zone=>zone.id!==editingZoneId);
|
||||
copyZone.innerHTML=`<option value="">${esc(tr('zones.copyFrom'))}</option>`+sources.map(z=>`<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
|
||||
copyZone.value=[...copyZone.options].some(option=>option.value===current)?current:'';
|
||||
const copyZone = $('#copyZoneSource');
|
||||
if (copyZone) {
|
||||
const current = copyZone.value;
|
||||
const editingZoneId = $('#zoneForm')?.dataset.editingZoneId || '';
|
||||
const sources = app.zones.filter(zone => zone.id !== editingZoneId);
|
||||
copyZone.innerHTML = `<option value="">${esc(tr('zones.copyFrom'))}</option>` + sources.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
|
||||
copyZone.value = [...copyZone.options].some(option => option.value === current) ? current : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+139
-139
@@ -19,9 +19,9 @@ document.addEventListener('click', async event => {
|
||||
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
|
||||
return;
|
||||
}
|
||||
if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; }
|
||||
if (button.dataset.go) { const more = $('#moreDialog'); if (more?.open) more.close(); showView(button.dataset.go); return; }
|
||||
if (button.dataset.open) {
|
||||
const form = document.getElementById(button.dataset.open.replace('Dialog','Form'));
|
||||
const form = document.getElementById(button.dataset.open.replace('Dialog', 'Form'));
|
||||
if (form) form.reset();
|
||||
if (button.dataset.open === 'zoneDialog') {
|
||||
if (form) { form.dataset.editingZoneId = ''; form.dataset.separateHysteresisInitialized = 'false'; if (form.elements.id) form.elements.id.value = ''; }
|
||||
@@ -39,12 +39,12 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; }
|
||||
if (button.dataset.chartLegend !== undefined) {
|
||||
const id=button.dataset.chartId;
|
||||
const runtime=chartRuntime.get(id);
|
||||
const index=Number(button.dataset.chartLegend);
|
||||
const item=runtime?.series?.[index];
|
||||
if(item){
|
||||
setChartSeriesHidden(id,item,index,!isChartSeriesHidden(id,item,index));
|
||||
const id = button.dataset.chartId;
|
||||
const runtime = chartRuntime.get(id);
|
||||
const index = Number(button.dataset.chartLegend);
|
||||
const item = runtime?.series?.[index];
|
||||
if (item) {
|
||||
setChartSeriesHidden(id, item, index, !isChartSeriesHidden(id, item, index));
|
||||
redrawHistoryChart(id);
|
||||
}
|
||||
return;
|
||||
@@ -53,7 +53,7 @@ document.addEventListener('click', async event => {
|
||||
const id = button.dataset.chartId;
|
||||
const current = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const next = button.dataset.chartZoom === 'in' ? Math.min(MAX_CHART_ZOOM, current * 1.5) : button.dataset.chartZoom === 'out' ? Math.max(1, current / 1.5) : 1;
|
||||
setChartZoom(id,next);
|
||||
setChartZoom(id, next);
|
||||
return;
|
||||
}
|
||||
if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; }
|
||||
@@ -63,33 +63,33 @@ document.addEventListener('click', async event => {
|
||||
if (!confirm(tr('schedules.replaceConfirm'))) return;
|
||||
try {
|
||||
button.disabled = true;
|
||||
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}});
|
||||
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, { method: 'POST', body: { template: button.dataset.scheduleTemplate } });
|
||||
await loadBootstrap(); toast(tr('schedules.templateApplied'));
|
||||
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.action; if (!action) return;
|
||||
const device = app.devices.find(v => v.id === button.dataset.device);
|
||||
if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) {
|
||||
if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power', 'temperature', 'mode', 'fan', 'toggle'].includes(action)) {
|
||||
return toast(tr('devices.disabledZoneTechnicalOnly'), true);
|
||||
}
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({power:!current?.power}));
|
||||
if (action === 'temperature' && device) return sendDeviceCommand(device.id, current => ({target_temperature:clamp(Number(current?.target_temperature ?? device.target_temperature)+Number(button.dataset.delta),8,30)}));
|
||||
if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true});
|
||||
if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)});
|
||||
if (action === 'toggle' && device) return sendDeviceCommand(device.id, current => ({[button.dataset.field]:!current?.[button.dataset.field]}));
|
||||
if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
|
||||
if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({ power: !current?.power }));
|
||||
if (action === 'temperature' && device) return sendDeviceCommand(device.id, current => ({ target_temperature: clamp(Number(current?.target_temperature ?? device.target_temperature) + Number(button.dataset.delta), 8, 30) }));
|
||||
if (action === 'mode' && device) return sendDeviceCommand(device.id, { mode: button.dataset.value, power: true });
|
||||
if (action === 'fan' && device) return sendDeviceCommand(device.id, { fan_speed: Number(button.dataset.value) });
|
||||
if (action === 'toggle' && device) return sendDeviceCommand(device.id, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
|
||||
if (action === 'poll' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' })); renderAll(); toast(tr('devices.readDone')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
|
||||
if (action === 'bind' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' })); renderAll(); toast(tr('devices.bound')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
|
||||
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
|
||||
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
|
||||
if (action === 'house-mode') {
|
||||
try {
|
||||
await enqueueClimateControlTask(async () => {
|
||||
await api('/api/house/control',{method:'POST', body:{mode:button.dataset.value}});
|
||||
await api('/api/house/control', { method: 'POST', body: { mode: button.dataset.value } });
|
||||
await loadBootstrap();
|
||||
});
|
||||
toast(tr('house.modeUpdated'));
|
||||
} catch(error){ toast(error.message,true); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
return;
|
||||
}
|
||||
if (action === 'house-power') {
|
||||
@@ -98,39 +98,39 @@ document.addEventListener('click', async event => {
|
||||
try {
|
||||
button.disabled = true;
|
||||
const result = await enqueueClimateControlTask(async () => {
|
||||
const response = await api('/api/house/power',{method:'POST', body:{power}});
|
||||
const response = await api('/api/house/power', { method: 'POST', body: { power } });
|
||||
await loadBootstrap();
|
||||
return response;
|
||||
});
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('house.powerPartial', {count: failed}), true);
|
||||
if (failed) toast(tr('house.powerPartial', { count: failed }), true);
|
||||
else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone'));
|
||||
} catch(error) { toast(error.message, true); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
if (action === 'house-preset') {
|
||||
try {
|
||||
const result = await enqueueClimateControlTask(async () => {
|
||||
const response = await api('/api/house/preset',{method:'POST', body:{preset:button.dataset.value}});
|
||||
const response = await api('/api/house/preset', { method: 'POST', body: { preset: button.dataset.value } });
|
||||
await loadBootstrap();
|
||||
return response;
|
||||
});
|
||||
const failed=Array.isArray(result.failed)?result.failed.length:0;
|
||||
if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated'));
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('house.powerPartial', { count: failed }), true); else toast(tr('house.presetUpdated'));
|
||||
}
|
||||
catch(error){ toast(error.message,true); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-power') { const power=button.dataset.value==='true'; if(!power) delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id,{power}); }
|
||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'group-preset') { delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); }
|
||||
if (action === 'group-power') { const power = button.dataset.value === 'true'; if (!power) delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id, { power }); }
|
||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id, { mode: button.dataset.value });
|
||||
if (action === 'group-preset') { delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id, { preset: button.dataset.value }); }
|
||||
if (action === 'group-custom-open') {
|
||||
const editor=button.closest('.group-card')?.querySelector('[data-group-custom-editor]');
|
||||
const input=editor?.querySelector('[data-group-custom-temperature]');
|
||||
const value=parseDecimal(input?.value);
|
||||
app.groupCustomDrafts[button.dataset.id]={open:true,value:Number.isFinite(value)?value:23};
|
||||
if(editor){ editor.hidden=false; input?.focus(); input?.select(); }
|
||||
const editor = button.closest('.group-card')?.querySelector('[data-group-custom-editor]');
|
||||
const input = editor?.querySelector('[data-group-custom-temperature]');
|
||||
const value = parseDecimal(input?.value);
|
||||
app.groupCustomDrafts[button.dataset.id] = { open: true, value: Number.isFinite(value) ? value : 23 };
|
||||
if (editor) { editor.hidden = false; input?.focus(); input?.select(); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-cancel') {
|
||||
@@ -139,31 +139,31 @@ document.addEventListener('click', async event => {
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-temperature') {
|
||||
const input=button.closest('.group-card')?.querySelector('[data-group-custom-temperature]');
|
||||
const value=parseDecimal(input?.value);
|
||||
if(!Number.isFinite(value) || value<8 || value>30) return toast(tr('groups.customTemperatureRange'),true);
|
||||
return sendGroupControl(button.dataset.id,{preset:'custom',setpoint:Math.round(value*10)/10});
|
||||
const input = button.closest('.group-card')?.querySelector('[data-group-custom-temperature]');
|
||||
const value = parseDecimal(input?.value);
|
||||
if (!Number.isFinite(value) || value < 8 || value > 30) return toast(tr('groups.customTemperatureRange'), true);
|
||||
return sendGroupControl(button.dataset.id, { preset: 'custom', setpoint: Math.round(value * 10) / 10 });
|
||||
}
|
||||
if (action === 'open-compressor-queue') return openCompressorQueueDialog();
|
||||
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
|
||||
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', {count:app.zones.filter(zone => !!zone.compressor_pending_action).length}))) return; return cancelAllCompressorTasks(); }
|
||||
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', { count: app.zones.filter(zone => !!zone.compressor_pending_action).length }))) return; return cancelAllCompressorTasks(); }
|
||||
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
||||
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
|
||||
if (action === 'zone-temperature') { const zone = app.zones.find(v => v.id === button.dataset.id); if (zone) { const base = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base + Number(button.dataset.delta)); } return; }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id, { mode: button.dataset.value });
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id, { preset: button.dataset.value });
|
||||
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id, { enabled: button.dataset.value === 'true' });
|
||||
if (action === 'zone-resume-automation') {
|
||||
const patch = button.dataset.resume === 'local'
|
||||
? {clear_local_thermostat_override:true}
|
||||
: {clear_device_manual_override:true};
|
||||
? { clear_local_thermostat_override: true }
|
||||
: { clear_device_manual_override: true };
|
||||
return sendZoneControl(button.dataset.id, patch);
|
||||
}
|
||||
if (action === 'zone-go-control') {
|
||||
showView('dashboard', {scroll:false});
|
||||
setDashboardTab('thermostats', {scroll:false});
|
||||
showView('dashboard', { scroll: false });
|
||||
setDashboardTab('thermostats', { scroll: false });
|
||||
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
|
||||
requestAnimationFrame(() => target?.scrollIntoView({behavior:'smooth', block:'center'}));
|
||||
requestAnimationFrame(() => target?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
||||
return;
|
||||
}
|
||||
if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
|
||||
@@ -178,7 +178,7 @@ document.addEventListener('click', async event => {
|
||||
if (action === 'revoke-access-token') {
|
||||
if (!confirm(tr('confirm.revokeToken'))) return;
|
||||
try {
|
||||
await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'});
|
||||
await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, { method: 'DELETE' });
|
||||
app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id);
|
||||
renderAccessTokens();
|
||||
toast(tr('toast.tokenRevoked'));
|
||||
@@ -196,14 +196,14 @@ $('#connectionRetry')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (app.ws) { try { app.ws.close(); } catch (_) {} app.ws = null; }
|
||||
if (app.ws) { try { app.ws.close(); } catch (_) { } app.ws = null; }
|
||||
await loadBootstrap(false);
|
||||
connectWebSocket();
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
$('#discoverButton').addEventListener('click', () => {
|
||||
const form=$('#discoverForm'); form.reset();
|
||||
form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
|
||||
const form = $('#discoverForm'); form.reset();
|
||||
form.protocol_version.value = '0'; form.passes.value = '3'; form.timeout_ms.value = String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
|
||||
openDialog('discoverDialog');
|
||||
});
|
||||
$('#historyRefresh').addEventListener('click', loadHistory);
|
||||
@@ -216,73 +216,73 @@ $('#settingsForm [name=notifications_provider]')?.addEventListener('change', upd
|
||||
$('#settingsForm [name=compressor_protection_enabled]')?.addEventListener('change', updateCompressorProtectionFields);
|
||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||
document.addEventListener('input', event => {
|
||||
const input=event.target.closest?.('[data-group-custom-temperature]');
|
||||
if(!input) return;
|
||||
const id=input.dataset.groupCustomTemperature;
|
||||
if(!id) return;
|
||||
app.groupCustomDrafts[id]={open:true,value:input.value};
|
||||
const input = event.target.closest?.('[data-group-custom-temperature]');
|
||||
if (!input) return;
|
||||
const id = input.dataset.groupCustomTemperature;
|
||||
if (!id) return;
|
||||
app.groupCustomDrafts[id] = { open: true, value: input.value };
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const input=event.target.closest?.('[data-group-custom-temperature]');
|
||||
if(!input || event.key!=='Enter') return;
|
||||
const input = event.target.closest?.('[data-group-custom-temperature]');
|
||||
if (!input || event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
input.closest('.group-card')?.querySelector('[data-action="group-custom-temperature"]')?.click();
|
||||
});
|
||||
|
||||
$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true}));
|
||||
$('#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(); });
|
||||
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
|
||||
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } });
|
||||
document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); });
|
||||
$('#simulationScope')?.addEventListener('change', event => { app.simulationScope = event.target.value; app.simulationTarget = 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget = event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationOpenTab')?.addEventListener('click', () => { const params = new URLSearchParams({ standalone: '1' }); if (app.simulationScope !== 'units') params.set('scope', app.simulationScope); if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
|
||||
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch (error) { toast(error.message, true); } });
|
||||
document.addEventListener('fullscreenchange', () => { const button = $('#simulationFullscreen'); if (button) button.textContent = tr(document.fullscreenElement ? 'simulation.exitFullscreen' : 'simulation.fullscreen'); });
|
||||
|
||||
$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60),alert_types:{stale_sensor:f.notification_alert_stale_sensor.checked,sensor_errors:f.notification_alert_sensor_errors.checked,communication:f.notification_alert_communication.checked,target_timeout:f.notification_alert_target_timeout.checked,automation:f.notification_alert_automation.checked,control_errors:f.notification_alert_control_errors.checked,important_events:f.notification_alert_important_events.checked,other:f.notification_alert_other.checked}}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast(tr('notifications.testSent'));}catch(e){toast(e.message,true);} });
|
||||
$('#testNotifications')?.addEventListener('click', async () => { const f = $('#settingsForm'); const body = { enabled: true, mode: f.notifications_mode.value, provider: f.notifications_provider.value, pushover_app_token: f.pushover_app_token.value.trim(), pushover_user_key: f.pushover_user_key.value.trim(), slack_webhook_url: f.slack_webhook_url.value.trim(), discord_webhook_url: f.discord_webhook_url.value.trim(), cooldown_seconds: Number(f.notification_cooldown_seconds.value || 300), communication_failure_threshold: Number(f.notification_failure_threshold.value || 3), target_timeout_minutes: Number(f.notification_target_timeout.value || 60), alert_types: { stale_sensor: f.notification_alert_stale_sensor.checked, sensor_errors: f.notification_alert_sensor_errors.checked, communication: f.notification_alert_communication.checked, target_timeout: f.notification_alert_target_timeout.checked, automation: f.notification_alert_automation.checked, control_errors: f.notification_alert_control_errors.checked, important_events: f.notification_alert_important_events.checked, other: f.notification_alert_other.checked } }; try { await api('/api/integrations/notifications/test', { method: 'POST', body }); toast(tr('notifications.testSent')); } catch (e) { toast(e.message, true); } });
|
||||
|
||||
$('#copyZoneSettings')?.addEventListener('click',()=>{
|
||||
const f=$('#zoneForm');
|
||||
const source=app.zones.find(zone=>zone.id===$('#copyZoneSource')?.value);
|
||||
if(!source)return;
|
||||
const targetId=f.dataset.editingZoneId || '';
|
||||
if(targetId && source.id===targetId){ toast(tr('zones.copyDifferent'),true); return; }
|
||||
$('#copyZoneSettings')?.addEventListener('click', () => {
|
||||
const f = $('#zoneForm');
|
||||
const source = app.zones.find(zone => zone.id === $('#copyZoneSource')?.value);
|
||||
if (!source) return;
|
||||
const targetId = f.dataset.editingZoneId || '';
|
||||
if (targetId && source.id === targetId) { toast(tr('zones.copyDifferent'), true); return; }
|
||||
|
||||
// Identity and sensor assignment belong to the destination profile and must never
|
||||
// be changed by copying thermostat tuning from another profile.
|
||||
const protectedValues={
|
||||
id:f.elements.id.value,
|
||||
name:f.elements.name.value,
|
||||
device_id:f.elements.device_id.value,
|
||||
sensor_source:f.elements.sensor_source.value,
|
||||
ha_entity_id:f.elements.ha_entity_id.value,
|
||||
enabled:f.elements.enabled.checked,
|
||||
const protectedValues = {
|
||||
id: f.elements.id.value,
|
||||
name: f.elements.name.value,
|
||||
device_id: f.elements.device_id.value,
|
||||
sensor_source: f.elements.sensor_source.value,
|
||||
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','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;
|
||||
const value=name==='external_sensor_weight_percent'?(source.external_sensor_weight*100):source[name];
|
||||
if(value!==undefined&&value!==null)input.value=String(value);
|
||||
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;
|
||||
const value = name === 'external_sensor_weight_percent' ? (source.external_sensor_weight * 100) : source[name];
|
||||
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;
|
||||
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';
|
||||
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;
|
||||
f.elements.name.value=protectedValues.name;
|
||||
f.elements.device_id.value=protectedValues.device_id;
|
||||
f.elements.sensor_source.value=protectedValues.sensor_source;
|
||||
f.elements.ha_entity_id.value=protectedValues.ha_entity_id;
|
||||
f.elements.enabled.checked=protectedValues.enabled;
|
||||
f.dataset.editingZoneId=targetId;
|
||||
f.elements.id.value = protectedValues.id;
|
||||
f.elements.name.value = protectedValues.name;
|
||||
f.elements.device_id.value = protectedValues.device_id;
|
||||
f.elements.sensor_source.value = protectedValues.sensor_source;
|
||||
f.elements.ha_entity_id.value = protectedValues.ha_entity_id;
|
||||
f.elements.enabled.checked = protectedValues.enabled;
|
||||
f.dataset.editingZoneId = targetId;
|
||||
updateZoneSensorFields();
|
||||
updateDirtyIndicator(f);
|
||||
toast(tr('zones.copiedFrom',{name:source.name}));
|
||||
toast(tr('zones.copiedFrom', { name: source.name }));
|
||||
});
|
||||
|
||||
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:tr('automations.presetHot'),trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:tr('automations.presetCold'),trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:tr('automations.presetMorning'),trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:tr('automations.presetNightOff'),trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; });
|
||||
$('#applyAutomationPreset')?.addEventListener('click', () => { const f = $('#automationForm'), p = $('#automationPreset')?.value; if (!p) return; const first = app.devices[0]?.id || ''; const presets = { hot: { name: tr('automations.presetHot'), trigger_kind: 'temperature_above', threshold: '28', action_power: 'true', action_mode: 'cool', action_target_temperature: '23', cooldown_seconds: '900' }, cold: { name: tr('automations.presetCold'), trigger_kind: 'temperature_below', threshold: '17', action_power: 'true', action_mode: 'heat', action_target_temperature: '21', cooldown_seconds: '900' }, morning: { name: tr('automations.presetMorning'), trigger_kind: 'time', at_time: '07:00', action_power: 'true', action_mode: 'auto', action_target_temperature: '22', cooldown_seconds: '3600' }, nightoff: { name: tr('automations.presetNightOff'), trigger_kind: 'time', at_time: '23:30', action_power: 'false', action_mode: '', action_target_temperature: '', cooldown_seconds: '3600' } }; const x = presets[p]; f.action_target_kind.value = 'device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k, v]) => { if (f[k]) f[k].value = v }); if (!f.trigger_device_id.value) f.trigger_device_id.value = first; if (!f.action_device_id.value) f.action_device_id.value = first; });
|
||||
|
||||
$('#tokenForm').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
@@ -297,11 +297,11 @@ $('#tokenForm').addEventListener('submit', async event => {
|
||||
});
|
||||
|
||||
$('#discoverForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
await runFormTask(form, async () => {
|
||||
const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}});
|
||||
form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []);
|
||||
}, {busyKey:'actions.discovering'});
|
||||
const result = await api('/api/discovery', { method: 'POST', body: { protocol_version: Number(raw.protocol_version), passes: Number(raw.passes), timeout_ms: Number(raw.timeout_ms) } });
|
||||
form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found', { count: result.count })); showDiscoveryNames(result.new_device_ids || []);
|
||||
}, { busyKey: 'actions.discovering' });
|
||||
});
|
||||
|
||||
$('#discoveryNamesForm').addEventListener('submit', async event => {
|
||||
@@ -309,7 +309,7 @@ $('#discoveryNamesForm').addEventListener('submit', async event => {
|
||||
const form = event.currentTarget;
|
||||
const inputs = $$('input[data-device-id]', form);
|
||||
await runFormTask(form, async () => {
|
||||
const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}})));
|
||||
const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, { method: 'PATCH', body: { name: input.value.trim() } })));
|
||||
updated.forEach(updateDevice);
|
||||
form.closest('dialog').close();
|
||||
renderAll();
|
||||
@@ -318,34 +318,34 @@ $('#discoveryNamesForm').addEventListener('submit', async event => {
|
||||
});
|
||||
|
||||
$('#renameDeviceForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
await runFormTask(form, async () => {
|
||||
const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}});
|
||||
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), protocol_version: Number(raw.protocol_version) } });
|
||||
updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#deviceForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form));
|
||||
data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked;
|
||||
event.preventDefault(); const form = event.currentTarget, data = Object.fromEntries(new FormData(form));
|
||||
data.port = Number(data.port); data.protocol_version = Number(data.protocol_version); data.simulated = form.simulated.checked;
|
||||
await runFormTask(form, async () => {
|
||||
await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added'));
|
||||
await api('/api/devices', { method: 'POST', body: data }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#zoneForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=form.dataset.editingZoneId || raw.id; const body={
|
||||
name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,
|
||||
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),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
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const id = form.dataset.editingZoneId || raw.id; const body = {
|
||||
name: raw.name, device_id: raw.device_id, enabled: form.enabled.checked,
|
||||
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), 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
|
||||
};
|
||||
await runFormTask(form, async () => {
|
||||
await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
await api(id ? `/api/zones/${encodeURIComponent(id)}` : '/api/zones', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,7 +364,7 @@ $('#temporaryThermostatStop')?.addEventListener('click', async event => {
|
||||
const id = event.currentTarget.dataset.id; if (!id) return;
|
||||
try {
|
||||
event.currentTarget.disabled = true;
|
||||
const zone = await enqueueZoneControlRequest(id, {clear_temporary_quick_thermostat:true});
|
||||
const zone = await enqueueZoneControlRequest(id, { clear_temporary_quick_thermostat: true });
|
||||
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
|
||||
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
@@ -378,7 +378,7 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
||||
const zone = app.zones.find(item => item.id === raw.zone_id);
|
||||
const existingSession = zone?.temporary_quick_thermostat;
|
||||
const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind;
|
||||
const request = {start_kind:startKind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)};
|
||||
const request = { start_kind: startKind, finish_kind: raw.finish_kind, target_temperature: parseDecimal(raw.target_temperature) };
|
||||
if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes);
|
||||
if (!activeSession && startKind === 'at') {
|
||||
const startAt = new Date(raw.start_at);
|
||||
@@ -391,11 +391,11 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
||||
if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true);
|
||||
const effectiveStart = activeSession ? new Date()
|
||||
: startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000)
|
||||
: startKind === 'at' ? new Date(raw.start_at) : new Date();
|
||||
: startKind === 'at' ? new Date(raw.start_at) : new Date();
|
||||
if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true);
|
||||
request.until = until.toISOString();
|
||||
}
|
||||
if (['temperature_reached','temperature_stable'].includes(raw.finish_kind)) {
|
||||
if (['temperature_reached', 'temperature_stable'].includes(raw.finish_kind)) {
|
||||
request.temperature_operator = raw.temperature_operator;
|
||||
request.tolerance_c = parseDecimal(raw.tolerance_c);
|
||||
if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes);
|
||||
@@ -403,41 +403,41 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
||||
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
|
||||
}
|
||||
await runFormTask(form, async () => {
|
||||
const updatedZone = await enqueueZoneControlRequest(raw.zone_id, {temporary_quick_thermostat:request});
|
||||
const updatedZone = await enqueueZoneControlRequest(raw.zone_id, { temporary_quick_thermostat: request });
|
||||
const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone;
|
||||
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
|
||||
}, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'});
|
||||
}, { busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting' });
|
||||
});
|
||||
|
||||
$('#groupForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id || '';
|
||||
const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value);
|
||||
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const id = raw.id || '';
|
||||
const zone_ids = $$('input[name=zone_ids]:checked', form).map(input => input.value);
|
||||
if (!zone_ids.length) return toast(tr('groups.chooseMember'), true);
|
||||
const existing=id ? app.groups.find(group => group.id===id) : null;
|
||||
const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false};
|
||||
const existing = id ? app.groups.find(group => group.id === id) : null;
|
||||
const body = { name: raw.name.trim(), zone_ids, power_enabled: existing?.power_enabled !== false };
|
||||
await runFormTask(form, async () => {
|
||||
await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body});
|
||||
await api(id ? `/api/groups/${encodeURIComponent(id)}` : '/api/groups', { method: id ? 'PUT' : 'POST', body });
|
||||
form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#scheduleForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
|
||||
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:parseDecimal(raw.setpoint||23)};
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const id = raw.id, weekdays = $$('[name=weekday]:checked', form).map(v => Number(v.value));
|
||||
const body = { name: raw.name, zone_id: raw.zone_id, enabled: form.enabled.checked, weekdays, start_time: raw.start_time, end_time: raw.end_time, preset: raw.preset, setpoint: parseDecimal(raw.setpoint || 23) };
|
||||
await runFormTask(form, async () => {
|
||||
await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
await api(id ? `/api/schedules/${encodeURIComponent(id)}` : '/api/schedules', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#automationForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
|
||||
const groupTarget=raw.action_target_kind==='group';
|
||||
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature);
|
||||
const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)};
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); const id = raw.id;
|
||||
const groupTarget = raw.action_target_kind === 'group';
|
||||
const action = {}; if (raw.action_power !== '') action.power = raw.action_power === 'true'; if (raw.action_mode) action.mode = raw.action_mode; if (!groupTarget && raw.action_target_temperature !== '') action.target_temperature = parseDecimal(raw.action_target_temperature);
|
||||
const body = { name: raw.name, enabled: form.enabled.checked, trigger_kind: raw.trigger_kind, trigger_device_id: raw.trigger_device_id || null, threshold: raw.threshold === '' ? null : parseDecimal(raw.threshold), at_time: raw.at_time || null, action_device_id: groupTarget ? '' : raw.action_device_id, action_group_id: groupTarget ? (raw.action_group_id || null) : null, action_preset: groupTarget ? (raw.action_preset || null) : null, action, cooldown_seconds: Number(raw.cooldown_seconds) };
|
||||
await runFormTask(form, async () => {
|
||||
await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
await api(id ? `/api/automations/${encodeURIComponent(id)}` : '/api/automations', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
+25
-25
@@ -75,7 +75,7 @@ function validationMessageFor(field) {
|
||||
if (field.validity?.valueMissing) return tr('validation.required');
|
||||
if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid');
|
||||
if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) {
|
||||
return tr('validation.range', {min:field.min || '—', max:field.max || '—'});
|
||||
return tr('validation.range', { min: field.min || '—', max: field.max || '—' });
|
||||
}
|
||||
if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid');
|
||||
return field.validity?.valid === false ? tr('validation.invalid') : '';
|
||||
@@ -95,7 +95,7 @@ function validateDecimalField(form, name, min, max) {
|
||||
if (!field || field.disabled) return true;
|
||||
const value = parseDecimal(field.value);
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
showFieldError(field, tr('validation.range', {min, max}));
|
||||
showFieldError(field, tr('validation.range', { min, max }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -114,7 +114,7 @@ function validateForm(form) {
|
||||
if (form.id === 'homeAssistantForm') {
|
||||
const url = form.elements.ha_url;
|
||||
if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
|
||||
['ha_entity_id','ha_outdoor_entity_id'].forEach(name => {
|
||||
['ha_entity_id', 'ha_outdoor_entity_id'].forEach(name => {
|
||||
const field = form.elements[name];
|
||||
if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; }
|
||||
});
|
||||
@@ -134,14 +134,14 @@ function validateForm(form) {
|
||||
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],
|
||||
['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8],
|
||||
['cool_comfort_setpoint', 8, 30], ['cool_sleep_setpoint', 8, 30], ['cool_away_setpoint', 8, 30],
|
||||
['heat_comfort_setpoint', 8, 30], ['heat_sleep_setpoint', 8, 30], ['heat_away_setpoint', 8, 30],
|
||||
['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8],
|
||||
...(form.elements.separate_hysteresis?.checked
|
||||
? [['cool_hysteresis',0.1,5],['heat_hysteresis',0.1,5]]
|
||||
: [['hysteresis',0.1,5]])
|
||||
? [['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; });
|
||||
zoneDecimalFields.forEach(([name, min, max]) => { if (!validateDecimalField(form, name, min, max)) valid = false; });
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
@@ -149,8 +149,8 @@ function validateForm(form) {
|
||||
const first = $('[aria-invalid="true"]', form);
|
||||
const pane = first?.closest?.('[data-settings-pane]');
|
||||
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
|
||||
first?.focus({preventScroll:true});
|
||||
first?.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
first?.focus({ preventScroll: true });
|
||||
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
@@ -160,12 +160,12 @@ function apiErrorField(form, message) {
|
||||
const candidates = [];
|
||||
if (text.includes('influx')) candidates.push('influx_url');
|
||||
if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url');
|
||||
if (text.includes('entity')) candidates.push('ha_entity_id','ha_outdoor_entity_id');
|
||||
if (text.includes('device')) candidates.push('device_id','action_device_id','trigger_device_id');
|
||||
if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature','cool_comfort_setpoint','action_target_temperature');
|
||||
if (text.includes('name')) candidates.push('name','controller_id');
|
||||
if (text.includes('token')) candidates.push('ha_token','influx_token','pushover_app_token');
|
||||
if (text.includes('url')) candidates.push('ha_url','influx_url','slack_webhook_url','discord_webhook_url');
|
||||
if (text.includes('entity')) candidates.push('ha_entity_id', 'ha_outdoor_entity_id');
|
||||
if (text.includes('device')) candidates.push('device_id', 'action_device_id', 'trigger_device_id');
|
||||
if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature', 'cool_comfort_setpoint', 'action_target_temperature');
|
||||
if (text.includes('name')) candidates.push('name', 'controller_id');
|
||||
if (text.includes('token')) candidates.push('ha_token', 'influx_token', 'pushover_app_token');
|
||||
if (text.includes('url')) candidates.push('ha_url', 'influx_url', 'slack_webhook_url', 'discord_webhook_url');
|
||||
return candidates.map(name => form?.elements?.[name]).find(Boolean) || null;
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ function presentFormError(form, error) {
|
||||
if (field) {
|
||||
const pane = field.closest?.('[data-settings-pane]');
|
||||
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
|
||||
field.focus({preventScroll:true});
|
||||
field.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
field.focus({ preventScroll: true });
|
||||
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
toast(message, true);
|
||||
}
|
||||
@@ -207,18 +207,18 @@ function setFormBusy(form, busy, busyKey = 'actions.saving') {
|
||||
}
|
||||
}
|
||||
|
||||
async function runFormTask(form, task, {busyKey='actions.saving', validate=true}={}) {
|
||||
if (!form || form.dataset.submitting === 'true') return {ok:false, duplicate:true};
|
||||
if (validate && !validateForm(form)) return {ok:false, validation:true};
|
||||
async function runFormTask(form, task, { busyKey = 'actions.saving', validate = true } = {}) {
|
||||
if (!form || form.dataset.submitting === 'true') return { ok: false, duplicate: true };
|
||||
if (validate && !validateForm(form)) return { ok: false, validation: true };
|
||||
clearFormErrors(form);
|
||||
setFormBusy(form, true, busyKey);
|
||||
try {
|
||||
const value = await task();
|
||||
markFormClean(form);
|
||||
return {ok:true, value};
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
presentFormError(form, error);
|
||||
return {ok:false, error};
|
||||
return { ok: false, error };
|
||||
} finally {
|
||||
setFormBusy(form, false);
|
||||
}
|
||||
@@ -229,7 +229,7 @@ function restoreTrackedForm(form) {
|
||||
if (form.id === 'settingsForm') renderSettings();
|
||||
else if (form.id === 'nightModeForm') renderNightSettings();
|
||||
else if (form.id === 'homeAssistantForm') {
|
||||
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
renderHomeAssistantSettings();
|
||||
} else markFormClean(form);
|
||||
}
|
||||
|
||||
+124
-124
@@ -1,161 +1,161 @@
|
||||
function renderHistorySummary() {
|
||||
const host=$('#historySummary'); if(!host) return;
|
||||
const deviceRows=app.historyData.devices, zoneRows=app.historyData.zones, sensorRows=app.historyData.sensors;
|
||||
const cards=[
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
const deviceRows = app.historyData.devices, zoneRows = app.historyData.zones, sensorRows = app.historyData.sensors;
|
||||
const cards = [
|
||||
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
|
||||
[tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')],
|
||||
[tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')],
|
||||
];
|
||||
host.innerHTML=cards.map(([label,value,detail])=>`<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
||||
host.innerHTML = cards.map(([label, value, detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderOverviewHistory() {
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('overviewIndoorChart',tr('history.allGreeIndoor'),tr('history.allGreeIndoorHint'))
|
||||
+ historyChartMarkup('overviewOutdoorChart',tr('history.allOutdoor'),tr('history.allOutdoorHint'))
|
||||
+ historyChartMarkup('overviewZonesChart',tr('history.allZoneControl'),tr('history.allZoneControlHint'));
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('overviewIndoorChart', tr('history.allGreeIndoor'), tr('history.allGreeIndoorHint'))
|
||||
+ historyChartMarkup('overviewOutdoorChart', tr('history.allOutdoor'), tr('history.allOutdoorHint'))
|
||||
+ historyChartMarkup('overviewZonesChart', tr('history.allZoneControl'), tr('history.allZoneControlHint'));
|
||||
|
||||
const deviceRows=app.historyData.devices;
|
||||
const indoorSeries=app.devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.indoor_temperature):NaN}));
|
||||
drawLineChart($('#overviewIndoorChart'),indoorSeries,deviceRows,{height:360}); renderLegend($('#overviewIndoorChartLegend'),indoorSeries);
|
||||
const deviceRows = app.historyData.devices;
|
||||
const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
||||
drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries);
|
||||
|
||||
const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
||||
const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))];
|
||||
const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(entity)}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')];
|
||||
const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries];
|
||||
drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries);
|
||||
const outdoorDeviceSeries = app.devices.filter(device => deviceRows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device, index) => ({ label: `${device.name} · ${tr('history.greeOutdoor')}`, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.outdoor_temperature) : NaN }));
|
||||
const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))];
|
||||
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
||||
const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')];
|
||||
const outdoorSeries = [...outdoorDeviceSeries, ...outdoorHaSeries];
|
||||
drawLineChart($('#overviewOutdoorChart'), outdoorSeries, outdoorRows, { height: 320 }); renderLegend($('#overviewOutdoorChartLegend'), outdoorSeries);
|
||||
|
||||
const zoneRows=app.historyData.zones;
|
||||
const zoneSeries=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN}));
|
||||
drawLineChart($('#overviewZonesChart'),zoneSeries,zoneRows,{height:340}); renderLegend($('#overviewZonesChartLegend'),zoneSeries);
|
||||
const zoneRows = app.historyData.zones;
|
||||
const zoneSeries = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
|
||||
drawLineChart($('#overviewZonesChart'), zoneSeries, zoneRows, { height: 340 }); renderLegend($('#overviewZonesChartLegend'), zoneSeries);
|
||||
}
|
||||
|
||||
function renderZoneHistory() {
|
||||
const selected=app.historyZone;
|
||||
const rows=selected==='all'?app.historyData.zones:app.historyData.zones.filter(row=>row.zone_id===selected);
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('zoneTemperatureChart',tr('history.temperatureOverview'),tr('history.temperatureOverviewHint'))+historyChartMarkup('zoneOperationChart',tr('history.operationOverview'),tr('history.operationOverviewHint'),true);
|
||||
if(selected==='all'){
|
||||
const series=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN}));
|
||||
drawLineChart($('#zoneTemperatureChart'),series,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),series);
|
||||
const targets=app.zones.map((zone,index)=>({label:`${zone.name} · ${tr('history.targetShort')}`,color:historySeriesColor(index),dash:[5,4],value:row=>row.zone_id===zone.id?historyNumber(row.target_temperature):NaN}));
|
||||
drawLineChart($('#zoneOperationChart'),targets,rows,{height:260}); renderLegend($('#zoneOperationChartLegend'),targets);
|
||||
const selected = app.historyZone;
|
||||
const rows = selected === 'all' ? app.historyData.zones : app.historyData.zones.filter(row => row.zone_id === selected);
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('zoneTemperatureChart', tr('history.temperatureOverview'), tr('history.temperatureOverviewHint')) + historyChartMarkup('zoneOperationChart', tr('history.operationOverview'), tr('history.operationOverviewHint'), true);
|
||||
if (selected === 'all') {
|
||||
const series = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
|
||||
drawLineChart($('#zoneTemperatureChart'), series, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), series);
|
||||
const targets = app.zones.map((zone, index) => ({ label: `${zone.name} · ${tr('history.targetShort')}`, color: historySeriesColor(index), dash: [5, 4], value: row => row.zone_id === zone.id ? historyNumber(row.target_temperature) : NaN }));
|
||||
drawLineChart($('#zoneOperationChart'), targets, rows, { height: 260 }); renderLegend($('#zoneOperationChartLegend'), targets);
|
||||
return;
|
||||
}
|
||||
const temperatureSeries=[
|
||||
{label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:row=>historyNumber(row.gree_temperature)},
|
||||
{label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:row=>historyNumber(row.external_temperature)},
|
||||
{label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:row=>historyNumber(row.control_temperature)},
|
||||
{label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:row=>historyNumber(row.target_temperature)},
|
||||
{label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:row=>historyNumber(row.device_setpoint)},
|
||||
{label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:row=>historyNumber(row.outdoor_temperature)},
|
||||
const temperatureSeries = [
|
||||
{ label: tr('history.greeSensor'), color: cssColor('--accent', '#3ecf8e'), value: row => historyNumber(row.gree_temperature) },
|
||||
{ label: tr('history.roomSensor'), color: cssColor('--info', '#60a5fa'), value: row => historyNumber(row.external_temperature) },
|
||||
{ label: tr('history.controlTemperature'), color: cssColor('--teal', '#2dd4bf'), width: 2.8, value: row => historyNumber(row.control_temperature) },
|
||||
{ label: tr('history.comfortTarget'), color: cssColor('--warning', '#f59e0b'), dash: [7, 5], value: row => historyNumber(row.target_temperature) },
|
||||
{ label: tr('history.deviceSetpoint'), color: cssColor('--purple', '#a78bfa'), dash: [3, 4], value: row => historyNumber(row.device_setpoint) },
|
||||
{ label: tr('history.outdoorTemperature'), color: cssColor('--muted-strong', '#9ca3af'), dash: [2, 5], value: row => historyNumber(row.outdoor_temperature) },
|
||||
];
|
||||
drawLineChart($('#zoneTemperatureChart'),temperatureSeries,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),temperatureSeries);
|
||||
const operationSeries=[
|
||||
{label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed),tooltipValue:value=>fanLabel(Math.round(value))},
|
||||
{label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5,tooltipValue:(_,row)=>row.demand?tr('common.active'):tr('common.disabled')},
|
||||
{label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5,tooltipValue:(_,row)=>row.power?tr('common.on'):tr('common.off')},
|
||||
drawLineChart($('#zoneTemperatureChart'), temperatureSeries, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), temperatureSeries);
|
||||
const operationSeries = [
|
||||
{ label: tr('history.fanSpeed'), color: cssColor('--info', '#60a5fa'), step: true, value: row => historyNumber(row.fan_speed), tooltipValue: value => fanLabel(Math.round(value)) },
|
||||
{ label: tr('history.demand'), color: cssColor('--accent', '#3ecf8e'), step: true, width: 2.4, value: row => row.demand ? 4.5 : 0.5, tooltipValue: (_, row) => row.demand ? tr('common.active') : tr('common.disabled') },
|
||||
{ label: tr('history.power'), color: cssColor('--warning', '#f59e0b'), step: true, dash: [5, 4], value: row => row.power ? 3.5 : 0.5, tooltipValue: (_, row) => row.power ? tr('common.on') : tr('common.off') },
|
||||
];
|
||||
drawLineChart($('#zoneOperationChart'),operationSeries,rows,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#zoneOperationChartLegend'),operationSeries);
|
||||
drawLineChart($('#zoneOperationChart'), operationSeries, rows, { height: 260, minValue: 0, maxValue: 5, binaryLabels: true }); renderLegend($('#zoneOperationChartLegend'), operationSeries);
|
||||
}
|
||||
|
||||
function renderDeviceHistory() {
|
||||
const selected=app.historyDevice;
|
||||
const rows=selected==='all'?app.historyData.devices:app.historyData.devices.filter(row=>row.device_id===selected);
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('deviceIndoorChart',tr('history.deviceIndoor'),tr('history.deviceIndoorHint'))+historyChartMarkup('deviceOutdoorChart',tr('history.deviceOutdoor'),tr('history.deviceOutdoorHint'))+historyChartMarkup('deviceTargetChart',tr('history.deviceTargets'),tr('history.deviceTargetsHint'),true);
|
||||
const devices=selected==='all'?app.devices:app.devices.filter(device=>device.id===selected);
|
||||
const indoor=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.indoor_temperature):NaN}));
|
||||
const outdoor=devices.filter(device=>rows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
||||
const targets=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),dash:[6,4],value:row=>row.device_id===device.id?historyNumber(row.target_temperature):NaN}));
|
||||
drawLineChart($('#deviceIndoorChart'),indoor,rows,{height:350}); renderLegend($('#deviceIndoorChartLegend'),indoor);
|
||||
drawLineChart($('#deviceOutdoorChart'),outdoor,rows,{height:310}); renderLegend($('#deviceOutdoorChartLegend'),outdoor);
|
||||
drawLineChart($('#deviceTargetChart'),targets,rows,{height:260}); renderLegend($('#deviceTargetChartLegend'),targets);
|
||||
const selected = app.historyDevice;
|
||||
const rows = selected === 'all' ? app.historyData.devices : app.historyData.devices.filter(row => row.device_id === selected);
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true);
|
||||
const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected);
|
||||
const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
||||
const outdoor = devices.filter(device => rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.outdoor_temperature) : NaN }));
|
||||
const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN }));
|
||||
drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor);
|
||||
drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor);
|
||||
drawLineChart($('#deviceTargetChart'), targets, rows, { height: 260 }); renderLegend($('#deviceTargetChartLegend'), targets);
|
||||
}
|
||||
|
||||
function renderSensorHistory() {
|
||||
const selected=app.historySensor;
|
||||
const rows=selected==='all'?app.historyData.sensors:app.historyData.sensors.filter(row=>row.entity_id===selected);
|
||||
const entities=[...new Set(rows.map(row=>row.entity_id))];
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint'));
|
||||
const series=entities.map((entity,index)=>({label:haSensorLabel(entity),color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series);
|
||||
const selected = app.historySensor;
|
||||
const rows = selected === 'all' ? app.historyData.sensors : app.historyData.sensors.filter(row => row.entity_id === selected);
|
||||
const entities = [...new Set(rows.map(row => row.entity_id))];
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('haSensorsChart', tr('history.haSensors'), tr('history.haSensorsHint'));
|
||||
const series = entities.map((entity, index) => ({ label: haSensorLabel(entity), color: historySeriesColor(index), dash: rows.some(row => row.entity_id === entity && row.kind === 'outdoor') ? [6, 4] : [], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
||||
drawLineChart($('#haSensorsChart'), series, rows, { height: 370 }); renderLegend($('#haSensorsChartLegend'), series);
|
||||
}
|
||||
|
||||
function customSeriesOptions() {
|
||||
const items=[];
|
||||
app.devices.forEach(device=>{
|
||||
items.push([`device|${device.id}|indoor`,`${device.name} · ${tr('history.indoorTemperature')}`]);
|
||||
items.push([`device|${device.id}|outdoor`,`${device.name} · ${tr('history.greeOutdoor')}`]);
|
||||
items.push([`device|${device.id}|target`,`${device.name} · ${tr('history.deviceTarget')}`]);
|
||||
const items = [];
|
||||
app.devices.forEach(device => {
|
||||
items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]);
|
||||
items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
|
||||
items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]);
|
||||
});
|
||||
app.zones.forEach(zone=>{
|
||||
items.push([`zone|${zone.id}|control`,`${zone.name} · ${tr('history.controlTemperature')}`]);
|
||||
items.push([`zone|${zone.id}|gree`,`${zone.name} · ${tr('history.greeSensor')}`]);
|
||||
items.push([`zone|${zone.id}|external`,`${zone.name} · ${tr('history.roomSensor')}`]);
|
||||
items.push([`zone|${zone.id}|target`,`${zone.name} · ${tr('history.comfortTarget')}`]);
|
||||
items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
||||
items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
||||
app.zones.forEach(zone => {
|
||||
items.push([`zone|${zone.id}|control`, `${zone.name} · ${tr('history.controlTemperature')}`]);
|
||||
items.push([`zone|${zone.id}|gree`, `${zone.name} · ${tr('history.greeSensor')}`]);
|
||||
items.push([`zone|${zone.id}|external`, `${zone.name} · ${tr('history.roomSensor')}`]);
|
||||
items.push([`zone|${zone.id}|target`, `${zone.name} · ${tr('history.comfortTarget')}`]);
|
||||
items.push([`zone|${zone.id}|device_target`, `${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
||||
items.push([`zone|${zone.id}|outdoor`, `${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
||||
});
|
||||
[...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`]));
|
||||
[...new Set(app.historyData.sensors.map(row => row.entity_id))].forEach(entity => items.push([`ha|${entity}|temperature`, `HA · ${haSensorLabel(entity)}`]));
|
||||
return items;
|
||||
}
|
||||
|
||||
function customSeriesDefinition(key,index=0) {
|
||||
const [kind,id,field]=String(key).split('|');
|
||||
const color=historySeriesColor(index);
|
||||
if(kind==='device'){
|
||||
const device=app.devices.find(item=>item.id===id); if(!device) return null;
|
||||
const labels={indoor:tr('history.indoorTemperature'),outdoor:tr('history.greeOutdoor'),target:tr('history.deviceTarget')};
|
||||
const fields={indoor:'indoor_temperature',outdoor:'outdoor_temperature',target:'target_temperature'};
|
||||
return {key,label:`${device.name} · ${labels[field]||field}`,color,dash:field==='target'?[6,4]:[],value:row=>!row.zone_id&&!row.entity_id&&row.device_id===id?historyNumber(row[fields[field]]):NaN};
|
||||
function customSeriesDefinition(key, index = 0) {
|
||||
const [kind, id, field] = String(key).split('|');
|
||||
const color = historySeriesColor(index);
|
||||
if (kind === 'device') {
|
||||
const device = app.devices.find(item => item.id === id); if (!device) return null;
|
||||
const labels = { indoor: tr('history.indoorTemperature'), outdoor: tr('history.greeOutdoor'), target: tr('history.deviceTarget') };
|
||||
const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' };
|
||||
return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN };
|
||||
}
|
||||
if(kind==='zone'){
|
||||
const zone=app.zones.find(item=>item.id===id); if(!zone) return null;
|
||||
const fields={control:['control_temperature',tr('history.controlTemperature')],gree:['gree_temperature',tr('history.greeSensor')],external:['external_temperature',tr('history.roomSensor')],target:['target_temperature',tr('history.comfortTarget')],device_target:['device_setpoint',tr('history.deviceSetpoint')],outdoor:['outdoor_temperature',tr('history.outdoorTemperature')]};
|
||||
const info=fields[field]; if(!info) return null;
|
||||
return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN};
|
||||
if (kind === 'zone') {
|
||||
const zone = app.zones.find(item => item.id === id); if (!zone) return null;
|
||||
const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] };
|
||||
const info = fields[field]; if (!info) return null;
|
||||
return { key, label: `${zone.name} · ${info[1]}`, color, dash: ['target', 'device_target', 'outdoor'].includes(field) ? [6, 4] : [], value: row => row.zone_id === id ? historyNumber(row[info[0]]) : NaN };
|
||||
}
|
||||
if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN};
|
||||
if (kind === 'ha') return { key, label: `HA · ${haSensorLabel(id)}`, color, dash: [3, 4], value: row => row.entity_id === id ? historyNumber(row.temperature) : NaN };
|
||||
return null;
|
||||
}
|
||||
|
||||
function persistSavedCharts() {
|
||||
localStorage.setItem('gree_controller_saved_charts',JSON.stringify(app.savedCharts.slice(0,30)));
|
||||
localStorage.setItem('gree_controller_saved_charts', JSON.stringify(app.savedCharts.slice(0, 30)));
|
||||
}
|
||||
|
||||
function renderCustomBuilder() {
|
||||
const host=$('#historyCustomBuilder'); if(!host) return;
|
||||
if(app.historyTab!=='custom'){host.innerHTML='';host.classList.remove('active');return;}
|
||||
const host = $('#historyCustomBuilder'); if (!host) return;
|
||||
if (app.historyTab !== 'custom') { host.innerHTML = ''; host.classList.remove('active'); return; }
|
||||
host.classList.add('active');
|
||||
const options=customSeriesOptions();
|
||||
const selected=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean);
|
||||
host.innerHTML=`<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
|
||||
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value,label])=>`<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
|
||||
<div class="custom-series-list">${selected.length?selected.map((item,index)=>`<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="${esc(tr('actions.remove'))}">×</button></span>`).join(''):`<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
|
||||
const options = customSeriesOptions();
|
||||
const selected = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
|
||||
host.innerHTML = `<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
|
||||
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
|
||||
<div class="custom-series-list">${selected.length ? selected.map((item, index) => `<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="${esc(tr('actions.remove'))}">×</button></span>`).join('') : `<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
|
||||
<div class="custom-chart-save"><input id="customChartName" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(tr('actions.save'))}</button><button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
|
||||
<div class="saved-chart-list">${app.savedCharts.length?app.savedCharts.map(item=>`<div class="saved-chart-row"><button data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))}</small></button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}">×</button></div>`).join(''):`<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div>
|
||||
<div class="saved-chart-list">${app.savedCharts.length ? app.savedCharts.map(item => `<div class="saved-chart-row"><button data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))}</small></button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}">×</button></div>`).join('') : `<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCustomHistory() {
|
||||
renderCustomBuilder();
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('customHistoryChart',tr('history.customChart'),tr('history.customChartHint'));
|
||||
const series=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean);
|
||||
const rows=[...app.historyData.devices,...app.historyData.zones,...app.historyData.sensors];
|
||||
drawLineChart($('#customHistoryChart'),series,rows,{height:390}); renderLegend($('#customHistoryChartLegend'),series);
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('customHistoryChart', tr('history.customChart'), tr('history.customChartHint'));
|
||||
const series = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
|
||||
const rows = [...app.historyData.devices, ...app.historyData.zones, ...app.historyData.sensors];
|
||||
drawLineChart($('#customHistoryChart'), series, rows, { height: 390 }); renderLegend($('#customHistoryChartLegend'), series);
|
||||
}
|
||||
|
||||
function renderHistoryPage() {
|
||||
renderHistorySummary();
|
||||
renderCustomBuilder();
|
||||
if(app.historyTab==='overview') renderOverviewHistory();
|
||||
else if(app.historyTab==='zones') renderZoneHistory();
|
||||
else if(app.historyTab==='devices') renderDeviceHistory();
|
||||
else if(app.historyTab==='sensors') renderSensorHistory();
|
||||
if (app.historyTab === 'overview') renderOverviewHistory();
|
||||
else if (app.historyTab === 'zones') renderZoneHistory();
|
||||
else if (app.historyTab === 'devices') renderDeviceHistory();
|
||||
else if (app.historyTab === 'sensors') renderSensorHistory();
|
||||
else renderCustomHistory();
|
||||
}
|
||||
|
||||
@@ -164,34 +164,34 @@ function drawCurrentChartIfVisible() {
|
||||
}
|
||||
|
||||
async function handleHistoryAction(button) {
|
||||
const action=button.dataset.historyAction;
|
||||
if(action==='add-series'){
|
||||
const value=$('#customSeriesSelect')?.value;
|
||||
if(value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
|
||||
const action = button.dataset.historyAction;
|
||||
if (action === 'add-series') {
|
||||
const value = $('#customSeriesSelect')?.value;
|
||||
if (value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
||||
}
|
||||
if(action==='remove-series'){
|
||||
app.customChartSeries.splice(Number(button.dataset.index),1);
|
||||
if (action === 'remove-series') {
|
||||
app.customChartSeries.splice(Number(button.dataset.index), 1);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
||||
}
|
||||
if(action==='save-chart'){
|
||||
if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const name=$('#customChartName')?.value.trim() || tr('history.customChart');
|
||||
const item={id:`chart-${Date.now()}`,name,series:[...app.customChartSeries],hours:$('#historyHours')?.value||'24'};
|
||||
if (action === 'save-chart') {
|
||||
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const name = $('#customChartName')?.value.trim() || tr('history.customChart');
|
||||
const item = { id: `chart-${Date.now()}`, name, series: [...app.customChartSeries], hours: $('#historyHours')?.value || '24' };
|
||||
app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return;
|
||||
}
|
||||
if(action==='load-chart'){
|
||||
const item=app.savedCharts.find(entry=>entry.id===button.dataset.id); if(!item) return;
|
||||
app.customChartSeries=[...item.series]; if($('#historyHours')&&item.hours) $('#historyHours').value=String(item.hours);
|
||||
if (action === 'load-chart') {
|
||||
const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return;
|
||||
app.customChartSeries = [...item.series]; if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return;
|
||||
}
|
||||
if(action==='delete-chart'){
|
||||
app.savedCharts=app.savedCharts.filter(entry=>entry.id!==button.dataset.id); persistSavedCharts(); renderCustomHistory(); return;
|
||||
if (action === 'delete-chart') {
|
||||
app.savedCharts = app.savedCharts.filter(entry => entry.id !== button.dataset.id); persistSavedCharts(); renderCustomHistory(); return;
|
||||
}
|
||||
if(action==='copy-chart-link'){
|
||||
if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${APP_BASE}${path}`;
|
||||
try { await navigator.clipboard.writeText(link); } catch (_) { const area=document.createElement('textarea'); area.value=link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
|
||||
if (action === 'copy-chart-link') {
|
||||
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const path = currentHistoryPath(); updateBrowserUrl(path, true); const link = `${location.origin}${APP_BASE}${path}`;
|
||||
try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
|
||||
toast(tr('history.linkCopied')); return;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
let historyResizeTimer;
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } });
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer = setTimeout(drawCurrentChartIfVisible, 120); } });
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); });
|
||||
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register(withBase('/sw.js')).catch(()=>{}));
|
||||
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register(withBase('/sw.js')).catch(() => { }));
|
||||
|
||||
async function startApplication() {
|
||||
observeTopbarHeight();
|
||||
|
||||
+36
-38
@@ -1,5 +1,5 @@
|
||||
function setDashboardTab(tab, {scroll=true}={}) {
|
||||
const next = ['main','thermostats','manual'].includes(tab) ? tab : 'main';
|
||||
function setDashboardTab(tab, { scroll = true } = {}) {
|
||||
const next = ['main', 'thermostats', 'manual'].includes(tab) ? tab : 'main';
|
||||
app.dashboardTab = next;
|
||||
$$('[data-dashboard-tab]').forEach(button => {
|
||||
const active = button.dataset.dashboardTab === next;
|
||||
@@ -11,23 +11,23 @@ function setDashboardTab(tab, {scroll=true}={}) {
|
||||
panel.classList.toggle('active', active);
|
||||
panel.hidden = !active;
|
||||
});
|
||||
if (scroll && app.currentView === 'dashboard') window.scrollTo({top:0, behavior:'smooth'});
|
||||
if (scroll && app.currentView === 'dashboard') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function showView(name, {push=true, scroll=true}={}) {
|
||||
function showView(name, { push = true, scroll = true } = {}) {
|
||||
if (!canLeaveCurrentView(name)) return false;
|
||||
app.currentView = name;
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name))));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
||||
if (push) updateBrowserUrl(pathForView(name));
|
||||
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
if (name === 'dashboard') setDashboardTab(app.dashboardTab, {scroll:false});
|
||||
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
|
||||
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
||||
if (name === 'logs') loadLogs();
|
||||
return true;
|
||||
}
|
||||
|
||||
function showHistoryTab(tab, {push=true, load=true}={}) {
|
||||
function showHistoryTab(tab, { push = true, load = true } = {}) {
|
||||
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
|
||||
renderHistoryNavigation();
|
||||
if (push) updateBrowserUrl(currentHistoryPath());
|
||||
@@ -36,10 +36,10 @@ function showHistoryTab(tab, {push=true, load=true}={}) {
|
||||
|
||||
async function sendDeviceCommand(id, commandOrFactory) {
|
||||
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => {}).then(() => {
|
||||
const request = previous.catch(() => { }).then(() => {
|
||||
const current = app.devices.find(device => device.id === id);
|
||||
const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory;
|
||||
return api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command});
|
||||
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body: command });
|
||||
});
|
||||
app.deviceControlQueue[id] = request;
|
||||
try {
|
||||
@@ -53,11 +53,11 @@ async function sendDeviceCommand(id, commandOrFactory) {
|
||||
|
||||
function enqueueClimateControlTask(task) {
|
||||
const previous = app.climateControlQueue || Promise.resolve();
|
||||
const request = previous.catch(() => {}).then(task);
|
||||
const request = previous.catch(() => { }).then(task);
|
||||
app.climateControlQueue = request;
|
||||
request.finally(() => {
|
||||
if (app.climateControlQueue === request) app.climateControlQueue = null;
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -68,13 +68,13 @@ function updateDevice(device) {
|
||||
|
||||
function enqueueZoneControlRequest(id, patch) {
|
||||
const previous = app.zoneControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => {}).then(() =>
|
||||
api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch})
|
||||
const request = previous.catch(() => { }).then(() =>
|
||||
api(`/api/zones/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch })
|
||||
);
|
||||
app.zoneControlQueue[id] = request;
|
||||
request.finally(() => {
|
||||
if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id];
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ async function sendZoneLocalPower(id, power) {
|
||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||
app.zoneControlSeq[id] = sequence;
|
||||
try {
|
||||
const zone = await enqueueZoneControlRequest(id, {power});
|
||||
const zone = await enqueueZoneControlRequest(id, { power });
|
||||
if (app.zoneControlSeq[id] !== sequence) return;
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
@@ -110,7 +110,7 @@ async function sendZoneControl(id, patch) {
|
||||
|
||||
async function cancelCompressorTask(id) {
|
||||
try {
|
||||
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, {method:'POST'});
|
||||
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, { method: 'POST' });
|
||||
if (result.zone) {
|
||||
const index = app.zones.findIndex(item => item.id === result.zone.id);
|
||||
if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone);
|
||||
@@ -122,17 +122,17 @@ async function cancelCompressorTask(id) {
|
||||
|
||||
async function cancelAllCompressorTasks() {
|
||||
try {
|
||||
const result = await api('/api/compressor-queue/cancel-all', {method:'POST'});
|
||||
const result = await api('/api/compressor-queue/cancel-all', { method: 'POST' });
|
||||
(result.zones || []).forEach(zone => {
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
});
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
toast(tr('zones.queuedCancelledAll', {count:Number(result.cancelled || 0)}));
|
||||
toast(tr('zones.queuedCancelledAll', { count: Number(result.cancelled || 0) }));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
|
||||
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;
|
||||
@@ -140,7 +140,7 @@ function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
|
||||
zone.effective_setpoint = next;
|
||||
renderZones();
|
||||
clearTimeout(app.zoneTemperatureTimers[zone.id]);
|
||||
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160);
|
||||
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, { setpoint: next }), 160);
|
||||
}
|
||||
|
||||
function beginInlineTemperatureEdit(target) {
|
||||
@@ -168,23 +168,23 @@ function beginInlineTemperatureEdit(target) {
|
||||
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);
|
||||
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});
|
||||
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))});
|
||||
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});
|
||||
input.addEventListener('blur', () => finish(true), { once: true });
|
||||
}
|
||||
|
||||
function showDiscoveryNames(ids) {
|
||||
@@ -208,8 +208,8 @@ function populateDeviceRename(id) {
|
||||
}
|
||||
|
||||
async function deleteEntity(type, id, labelKey) {
|
||||
if (!confirm(tr('confirm.delete', {label: tr(labelKey)}))) return;
|
||||
try { await api(`/api/${type}/${encodeURIComponent(id)}`, {method:'DELETE'}); await loadBootstrap(); toast(tr('common.removed')); }
|
||||
if (!confirm(tr('confirm.delete', { label: tr(labelKey) }))) return;
|
||||
try { await api(`/api/${type}/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadBootstrap(); toast(tr('common.removed')); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ function openDialog(id) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateZoneHysteresisFields({syncFromCommon=false}={}) {
|
||||
function updateZoneHysteresisFields({ syncFromCommon = false } = {}) {
|
||||
const form = $('#zoneForm');
|
||||
if (!form) return;
|
||||
const separate = !!form.separate_hysteresis?.checked;
|
||||
@@ -266,11 +266,11 @@ function updateAutomationTargetFields() {
|
||||
form.action_target_temperature.disabled = groupTarget;
|
||||
if (groupTarget) form.action_target_temperature.value = '';
|
||||
[...form.action_mode.options].forEach(option => {
|
||||
if (!['dry','fan'].includes(option.value)) return;
|
||||
if (!['dry', 'fan'].includes(option.value)) return;
|
||||
option.disabled = groupTarget;
|
||||
option.hidden = groupTarget;
|
||||
});
|
||||
if (groupTarget && ['dry','fan'].includes(form.action_mode.value)) form.action_mode.value = '';
|
||||
if (groupTarget && ['dry', 'fan'].includes(form.action_mode.value)) form.action_mode.value = '';
|
||||
}
|
||||
|
||||
function populateZone(id) {
|
||||
@@ -280,7 +280,7 @@ function populateZone(id) {
|
||||
form.dataset.editingZoneId = item.id;
|
||||
form.elements.id.value = item.id;
|
||||
fillSelects();
|
||||
Object.entries(item).forEach(([key,value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
|
||||
Object.entries(item).forEach(([key, value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
|
||||
form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool');
|
||||
if (Number(item.profile_version || 0) === 0) {
|
||||
if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21);
|
||||
@@ -303,7 +303,7 @@ const dateTimeLocalValue = value => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
const pad = number => String(number).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
function updateTemporaryThermostatFields() {
|
||||
@@ -320,7 +320,7 @@ function updateTemporaryThermostatFields() {
|
||||
startAt.hidden = startKind !== 'at';
|
||||
duration.hidden = kind !== 'duration';
|
||||
until.hidden = kind !== 'until';
|
||||
temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind);
|
||||
temperature.hidden = !['temperature_reached', 'temperature_stable'].includes(kind);
|
||||
schedule.hidden = kind !== 'schedule_boundary';
|
||||
$('#temporaryHoldField').hidden = kind !== 'temperature_stable';
|
||||
form.start_delay_minutes.required = startKind === 'delay';
|
||||
@@ -368,13 +368,11 @@ function populateTemporaryThermostat(id) {
|
||||
} else if (session.safety_expires_at) {
|
||||
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
|
||||
form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000));
|
||||
} else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) {
|
||||
} else if (['temperature_reached', 'temperature_stable'].includes(session.finish_kind)) {
|
||||
form.max_duration_minutes.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Once ownership has started, the historical start is informational. Editing changes only
|
||||
// target/finish rules; rescheduling requires stopping the session and creating a new one.
|
||||
form.start_kind.disabled = activeSession;
|
||||
form.start_delay_minutes.disabled = activeSession;
|
||||
form.start_at.disabled = activeSession;
|
||||
@@ -393,7 +391,7 @@ function populateTemporaryThermostat(id) {
|
||||
function populateSchedule(id) {
|
||||
const item = app.schedules.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#scheduleForm'); form.reset(); fillSelects();
|
||||
['id','name','zone_id','start_time','end_time','setpoint','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
['id', 'name', 'zone_id', 'start_time', 'end_time', 'setpoint', 'preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
form.enabled.checked = item.enabled;
|
||||
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
|
||||
updateSchedulePresetField(); openDialog('scheduleDialog');
|
||||
@@ -402,7 +400,7 @@ function populateSchedule(id) {
|
||||
function populateAutomation(id) {
|
||||
const item = app.automations.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#automationForm'); form.reset(); fillSelects();
|
||||
['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
['id', 'name', 'trigger_kind', 'trigger_device_id', 'threshold', 'at_time', 'action_device_id', 'cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
const groupTarget = !!item.action_group_id;
|
||||
form.action_target_kind.value = groupTarget ? 'group' : 'device';
|
||||
if (groupTarget) form.action_group_id.value = item.action_group_id;
|
||||
|
||||
+21
-21
@@ -2,8 +2,8 @@ async function loadLogs() {
|
||||
renderLogRetention();
|
||||
try {
|
||||
const data = await api('/api/events?limit=150');
|
||||
const level=$('#logLevelFilter')?.value||'all', category=$('#logCategoryFilter')?.value||'all';
|
||||
const logs = (data.events || []).filter(item => (level==='all'||item.level===level) && (category==='all'||logCategory(item.kind)===category));
|
||||
const level = $('#logLevelFilter')?.value || 'all', category = $('#logCategoryFilter')?.value || 'all';
|
||||
const logs = (data.events || []).filter(item => (level === 'all' || item.level === level) && (category === 'all' || logCategory(item.kind) === category));
|
||||
$('#logList').innerHTML = logs.length
|
||||
? logs.map(item => `<div class="log-row ${esc(item.level)} category-${esc(logCategory(item.kind))}"><time>${esc(new Date(item.timestamp).toLocaleTimeString(locale()))}</time><span class="log-category">${esc(logCategory(item.kind))}</span><span class="kind">${esc(item.kind)}</span><span class="message">${esc(item.message)}</span></div>`).join('')
|
||||
: `<div class="empty"><strong>${esc(tr('logs.emptyTitle'))}</strong>${esc(tr('logs.emptyText'))}</div>`;
|
||||
@@ -23,40 +23,40 @@ function connectWebSocket() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.systemSnapshotAt=Date.now(); app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.updated') {
|
||||
const settingsDirty=isFormDirty($('#settingsForm')), nightDirty=isFormDirty($('#nightModeForm')), haDirty=isFormDirty($('#homeAssistantForm'));
|
||||
app.settings=data;
|
||||
if (!haDirty) app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})};
|
||||
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
|
||||
app.settings = data;
|
||||
if (!haDirty) app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
if (!settingsDirty) renderSettings();
|
||||
if (!nightDirty) renderNightSettings();
|
||||
if (!haDirty) renderHomeAssistantSettings();
|
||||
renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad();
|
||||
renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad();
|
||||
}
|
||||
else if (message.event === 'house.power_changed') { renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'gree.frame_received') {
|
||||
app.system = app.system || {};
|
||||
app.system.gree_received_frames = Number(data.total || 0);
|
||||
app.system.gree_received_frames_by_device = {...(app.system.gree_received_frames_by_device || {})};
|
||||
app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) };
|
||||
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
|
||||
renderGreeFrameStats();
|
||||
renderSystemInfo();
|
||||
}
|
||||
else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||
else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
||||
else if (message.event === 'log.created') { if(app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if(app.currentView === 'logs') loadLogs(); }
|
||||
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
||||
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
|
||||
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad();
|
||||
} catch (_) {}
|
||||
} catch (_) { }
|
||||
};
|
||||
}
|
||||
|
||||
+2
-3
@@ -74,17 +74,16 @@ function applyRouteFromLocation() {
|
||||
const hours = params.get('hours');
|
||||
if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
|
||||
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
|
||||
if (!showView('history', {push: false, scroll: false})) restoreCurrentRoute(previousView);
|
||||
if (!showView('history', { push: false, scroll: false })) restoreCurrentRoute(previousView);
|
||||
return;
|
||||
}
|
||||
|
||||
const view = first === 'index.html' ? 'dashboard' : ROUTE_VIEWS[`/${first}`];
|
||||
if (!showView(view || 'dashboard', {push: false, scroll: false})) {
|
||||
if (!showView(view || 'dashboard', { push: false, scroll: false })) {
|
||||
restoreCurrentRoute(previousView);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep a canonical URL for the application root and legacy /index.html entry point.
|
||||
if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true);
|
||||
}
|
||||
|
||||
|
||||
+24
-24
@@ -65,23 +65,23 @@ function renderSettings() {
|
||||
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
|
||||
form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
|
||||
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
|
||||
const n=app.settings.notifications||{};
|
||||
form.notifications_enabled.checked=!!n.enabled; form.notifications_mode.value=n.mode||'problems'; form.notifications_provider.value=n.provider||'pushover';
|
||||
form.pushover_app_token.value=''; form.pushover_user_key.value=''; form.slack_webhook_url.value=''; form.discord_webhook_url.value='';
|
||||
form.pushover_app_token.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.applicationTokenPlaceholder');
|
||||
form.pushover_user_key.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.userKeyPlaceholder');
|
||||
form.slack_webhook_url.placeholder=n.slack_configured?tr('settings.secretSaved'):'https://hooks.slack.com/services/…';
|
||||
form.discord_webhook_url.placeholder=n.discord_configured?tr('settings.secretSaved'):'https://discord.com/api/webhooks/…';
|
||||
form.notification_cooldown_seconds.value=n.cooldown_seconds||300; form.notification_failure_threshold.value=n.communication_failure_threshold||3; form.notification_target_timeout.value=n.target_timeout_minutes||60;
|
||||
const alerts=n.alert_types||{};
|
||||
form.notification_alert_stale_sensor.checked=alerts.stale_sensor!==false;
|
||||
form.notification_alert_sensor_errors.checked=alerts.sensor_errors!==false;
|
||||
form.notification_alert_communication.checked=alerts.communication!==false;
|
||||
form.notification_alert_target_timeout.checked=alerts.target_timeout!==false;
|
||||
form.notification_alert_automation.checked=alerts.automation!==false;
|
||||
form.notification_alert_control_errors.checked=alerts.control_errors!==false;
|
||||
form.notification_alert_important_events.checked=alerts.important_events!==false;
|
||||
form.notification_alert_other.checked=alerts.other!==false;
|
||||
const n = app.settings.notifications || {};
|
||||
form.notifications_enabled.checked = !!n.enabled; form.notifications_mode.value = n.mode || 'problems'; form.notifications_provider.value = n.provider || 'pushover';
|
||||
form.pushover_app_token.value = ''; form.pushover_user_key.value = ''; form.slack_webhook_url.value = ''; form.discord_webhook_url.value = '';
|
||||
form.pushover_app_token.placeholder = n.pushover_configured ? tr('settings.secretSaved') : tr('notifications.applicationTokenPlaceholder');
|
||||
form.pushover_user_key.placeholder = n.pushover_configured ? tr('settings.secretSaved') : tr('notifications.userKeyPlaceholder');
|
||||
form.slack_webhook_url.placeholder = n.slack_configured ? tr('settings.secretSaved') : 'https://hooks.slack.com/services/…';
|
||||
form.discord_webhook_url.placeholder = n.discord_configured ? tr('settings.secretSaved') : 'https://discord.com/api/webhooks/…';
|
||||
form.notification_cooldown_seconds.value = n.cooldown_seconds || 300; form.notification_failure_threshold.value = n.communication_failure_threshold || 3; form.notification_target_timeout.value = n.target_timeout_minutes || 60;
|
||||
const alerts = n.alert_types || {};
|
||||
form.notification_alert_stale_sensor.checked = alerts.stale_sensor !== false;
|
||||
form.notification_alert_sensor_errors.checked = alerts.sensor_errors !== false;
|
||||
form.notification_alert_communication.checked = alerts.communication !== false;
|
||||
form.notification_alert_target_timeout.checked = alerts.target_timeout !== false;
|
||||
form.notification_alert_automation.checked = alerts.automation !== false;
|
||||
form.notification_alert_control_errors.checked = alerts.control_errors !== false;
|
||||
form.notification_alert_important_events.checked = alerts.important_events !== false;
|
||||
form.notification_alert_other.checked = alerts.other !== false;
|
||||
updateNotificationFields();
|
||||
updateInfluxFields();
|
||||
renderLogRetention();
|
||||
@@ -135,7 +135,7 @@ function renderSystemInfo() {
|
||||
[tr('settings.database'), database, 'mono'],
|
||||
[tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'],
|
||||
];
|
||||
host.innerHTML = `<div class="system-panel-head"><div><span class="eyebrow">${esc(tr('settings.systemState'))}</span><h3>${esc(tr('settings.systemStatusTitle'))}</h3><p>${esc(tr('settings.systemStateHint'))}</p></div><span class="system-health ${ready && connected ? 'ok' : 'warn'}">${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}</span></div><div class="system-status-grid">${items.map(([label,value,tone]) => `<div class="system-status-item ${esc(tone)}"><small>${esc(label)}</small><strong>${esc(value)}</strong></div>`).join('')}</div>`;
|
||||
host.innerHTML = `<div class="system-panel-head"><div><span class="eyebrow">${esc(tr('settings.systemState'))}</span><h3>${esc(tr('settings.systemStatusTitle'))}</h3><p>${esc(tr('settings.systemStateHint'))}</p></div><span class="system-health ${ready && connected ? 'ok' : 'warn'}">${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}</span></div><div class="system-status-grid">${items.map(([label, value, tone]) => `<div class="system-status-item ${esc(tone)}"><small>${esc(label)}</small><strong>${esc(value)}</strong></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderGreeFrameStats() {
|
||||
@@ -185,11 +185,11 @@ function updateInfluxFields() {
|
||||
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
|
||||
}
|
||||
|
||||
function updateNotificationFields(){ const provider=$('#settingsForm [name=notifications_provider]')?.value||'pushover'; $$('[data-notification-provider]').forEach(n=>n.hidden=n.dataset.notificationProvider!==provider); }
|
||||
function updateNotificationFields() { const provider = $('#settingsForm [name=notifications_provider]')?.value || 'pushover'; $$('[data-notification-provider]').forEach(n => n.hidden = n.dataset.notificationProvider !== provider); }
|
||||
|
||||
function logCategory(kind=''){ const prefix=String(kind).split('.')[0]; return ['device','zone','automation','settings'].includes(prefix)?prefix:(['home_assistant','influx','notification'].includes(prefix)?'integration':'system'); }
|
||||
function logCategory(kind = '') { const prefix = String(kind).split('.')[0]; return ['device', 'zone', 'automation', 'settings'].includes(prefix) ? prefix : (['home_assistant', 'influx', 'notification'].includes(prefix) ? 'integration' : 'system'); }
|
||||
function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) {
|
||||
app.debugLines.push({source, kind, message, timestamp, data});
|
||||
app.debugLines.push({ source, kind, message, timestamp, data });
|
||||
if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160);
|
||||
renderDebugOverlay();
|
||||
}
|
||||
@@ -199,7 +199,7 @@ function renderDebugOverlay() {
|
||||
const enabled = !!app.settings?.debug?.overlay_enabled;
|
||||
overlay.hidden = !enabled;
|
||||
if (!enabled) return;
|
||||
if (!['all','requests','gree'].includes(app.debugFilter)) app.debugFilter = 'all';
|
||||
if (!['all', 'requests', 'gree'].includes(app.debugFilter)) app.debugFilter = 'all';
|
||||
const status = $('#debugOverlayStatus');
|
||||
if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly');
|
||||
$$('[data-debug-filter]', overlay).forEach(button => {
|
||||
@@ -225,10 +225,10 @@ async function loadDebugBacklog() {
|
||||
if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return;
|
||||
try {
|
||||
const data = await api('/api/events?limit=60');
|
||||
app.debugLines = (data.events || []).reverse().map(item => ({source:'API', kind:item.kind, message:item.message, timestamp:item.timestamp, data:item.metadata})).slice(-120);
|
||||
app.debugLines = (data.events || []).reverse().map(item => ({ source: 'API', kind: item.kind, message: item.message, timestamp: item.timestamp, data: item.metadata })).slice(-120);
|
||||
app.debugBacklogLoaded = true;
|
||||
renderDebugOverlay();
|
||||
} catch (_) {}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
|
||||
+21
-21
@@ -66,7 +66,7 @@ function currentSettingsBody() {
|
||||
outdoor_entity_id: ha.outdoor_entity_id || '',
|
||||
sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300),
|
||||
allow_invalid_tls: !!ha.allow_invalid_tls,
|
||||
sensor_aliases: {...(ha.sensor_aliases || {})},
|
||||
sensor_aliases: { ...(ha.sensor_aliases || {}) },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -92,7 +92,7 @@ function settingsBodyFromForm(form) {
|
||||
org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token,
|
||||
history_threshold_days: Number(raw.influx_threshold_days),
|
||||
};
|
||||
body.debug = {overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked};
|
||||
body.debug = { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked };
|
||||
body.notifications = {
|
||||
enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider,
|
||||
pushover_app_token: raw.pushover_app_token || '', pushover_user_key: raw.pushover_user_key || '',
|
||||
@@ -139,14 +139,14 @@ function homeAssistantSettingsBodyFromForm(form) {
|
||||
outdoor_entity_id: raw.ha_outdoor_entity_id,
|
||||
sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))),
|
||||
allow_invalid_tls: form.ha_allow_invalid_tls.checked,
|
||||
sensor_aliases: {...(app.sensorAliases || {})},
|
||||
sensor_aliases: { ...(app.sensorAliases || {}) },
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
async function saveRuntimeSettings(body, notify = true) {
|
||||
app.settings = await api('/api/settings', {method:'PUT', body});
|
||||
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
|
||||
app.settings = await api('/api/settings', { method: 'PUT', body });
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
renderSettings();
|
||||
renderNightSettings();
|
||||
renderHomeAssistantSettings();
|
||||
@@ -202,10 +202,10 @@ document.addEventListener('click', event => {
|
||||
$('#saveLogRetention')?.addEventListener('click', async () => {
|
||||
const days = Number($('#logRetentionDays')?.value || 30);
|
||||
try {
|
||||
const result = await api('/api/events/retention', {method:'PUT', body:{days}});
|
||||
const result = await api('/api/events/retention', { method: 'PUT', body: { days } });
|
||||
app.settings.event_log_retention_days = result.days;
|
||||
$('#settingsForm').event_log_retention_days.value = result.days;
|
||||
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days}));
|
||||
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.days }));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ $('#createAccessToken').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}});
|
||||
const result = await api('/api/access-tokens', { method: 'POST', body: { name: 'Home Assistant' } });
|
||||
if (result.item) app.accessTokens.unshift(result.item);
|
||||
renderAccessTokens();
|
||||
$('#generatedAccessToken').value = result.token || '';
|
||||
@@ -236,7 +236,7 @@ $('#copyAccessToken').addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
$('#haTest').addEventListener('click', async event => {
|
||||
const form=$('#homeAssistantForm'), button=event.currentTarget;
|
||||
const form = $('#homeAssistantForm'), button = event.currentTarget;
|
||||
if (!validateForm(form)) return;
|
||||
button.disabled = true;
|
||||
const idle = button.textContent;
|
||||
@@ -244,11 +244,11 @@ $('#haTest').addEventListener('click', async event => {
|
||||
try {
|
||||
await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false);
|
||||
const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null;
|
||||
const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}});
|
||||
const result = await api('/api/integrations/home-assistant/test', { method: 'POST', body: { entity_id: entity } });
|
||||
markFormClean(form);
|
||||
toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)}));
|
||||
} catch(error){presentFormError(form,error);}
|
||||
finally { button.disabled=false; button.textContent=idle; }
|
||||
toast(tr('toast.haTemperature', { temperature: result.temperature_c.toFixed(1) }));
|
||||
} catch (error) { presentFormError(form, error); }
|
||||
finally { button.disabled = false; button.textContent = idle; }
|
||||
});
|
||||
|
||||
$('#simulationRefresh')?.addEventListener('click', async () => {
|
||||
@@ -259,10 +259,10 @@ $('#simulationRefresh')?.addEventListener('click', async () => {
|
||||
$('#exportSettings').addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await api('/api/settings/export');
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `gree-controller-settings-${new Date().toISOString().slice(0,10)}.json`;
|
||||
link.download = `gree-controller-settings-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href);
|
||||
toast(tr('toast.exported'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
@@ -274,7 +274,7 @@ $('#importSettingsFile').addEventListener('change', async event => {
|
||||
try {
|
||||
if (!confirm(tr('settings.importConfirm'))) return;
|
||||
const body = JSON.parse(await file.text());
|
||||
await api('/api/settings/import', {method:'POST', body});
|
||||
await api('/api/settings/import', { method: 'POST', body });
|
||||
app.debugBacklogLoaded = false;
|
||||
await loadBootstrap();
|
||||
toast(tr('toast.imported'));
|
||||
@@ -283,11 +283,11 @@ $('#importSettingsFile').addEventListener('change', async event => {
|
||||
});
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
const target=event.target;
|
||||
if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
|
||||
else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
|
||||
else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
|
||||
else if(target.name==='influx_version') updateInfluxFields();
|
||||
const target = event.target;
|
||||
if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.name === 'influx_version') updateInfluxFields();
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
{"name":"GREE Controller","short_name":"GREE","start_url":"./dashboard","scope":"./","display":"standalone","background_color":"#151515","theme_color":"#151515","icons":[{"src":"favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]}
|
||||
{
|
||||
"name": "GREE Controller",
|
||||
"short_name": "GREE",
|
||||
"start_url": "./dashboard",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#151515",
|
||||
"theme_color": "#151515",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+5695
-1022
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,6 @@ self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).the
|
||||
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
// API and language packs stay network-owned. Language JSON is deliberately not
|
||||
// cache-first so a newly deployed translation can never be masked by an old pack.
|
||||
if (event.request.method !== 'GET' || url.pathname.startsWith(path('/api/')) || url.pathname.startsWith(path('/lang/')) || event.request.mode === 'navigate') return;
|
||||
event.respondWith(caches.match(event.request).then(cached => cached || fetch(event.request).then(response => {
|
||||
const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response;
|
||||
|
||||
Reference in New Issue
Block a user