v0.6.0
This commit is contained in:
@@ -65,3 +65,31 @@ cargo build --release
|
|||||||
```
|
```
|
||||||
|
|
||||||
The update script must complete those commands before replacing the running binary.
|
The update script must complete those commands before replacing the running binary.
|
||||||
|
|
||||||
|
## 2026-08-24 update
|
||||||
|
|
||||||
|
Implemented from the current source archive:
|
||||||
|
|
||||||
|
- Decimal thermostat input accepts both `.` and `,` in mobile/desktop UI via locale-independent parser and decimal keyboard hints.
|
||||||
|
- Notifications persisted in runtime settings with Pushover, Slack and Discord providers, test endpoint/button, two modes (`problems`, `important`), per-event cooldown, communication failure threshold and target-not-reached timeout.
|
||||||
|
- Device polling emits one offline anomaly after the configured consecutive-failure threshold and one recovery event when communication returns.
|
||||||
|
- Zone control tracks continuous demand and emits a `zone.target_timeout` anomaly once per demand episode when the target is not reached within the configured time.
|
||||||
|
- Automation dialog includes practical presets for high-temperature cooling, low-temperature heating, morning comfort and night power-off.
|
||||||
|
- Event log page has level/category filters and stronger visual separation by category/severity.
|
||||||
|
- Zone editor can copy thermostat tuning/profile settings from another zone without overwriting the destination zone identity, device assignment or HA entity.
|
||||||
|
- Language selector shows flags; theme selector shows compact theme icons.
|
||||||
|
- Home Assistant custom component includes `icon.png` and `logo.png` based on the existing project mark.
|
||||||
|
- New installs create a minimal environment file, keep `SIMULATE=false` and `AUTO_SEED=false`, and intentionally allow an empty application token. Existing env files remain untouched.
|
||||||
|
- Reverse-proxy support includes base-path routing, `X-Forwarded-Prefix`, prefix-aware UI/API/WebSocket/PWA assets and helper script health URLs.
|
||||||
|
- Removed permissive CORS and added basic browser security headers. Notification webhooks are HTTPS-only, host-restricted and do not follow redirects.
|
||||||
|
- Added `docs/SECURITY.md` and `docs/REVERSE_PROXY.md`.
|
||||||
|
|
||||||
|
History review: the supplied counts (28,133 GREE, 1,889 zone, 5,626 HA samples) are modest for the existing SQLite design. The current schema already has time/entity indexes, history bucketing, query limits, retention and compaction, so no database migration was added solely for these counts.
|
||||||
|
|
||||||
|
Validation performed in the build environment:
|
||||||
|
|
||||||
|
- `node --check web/app.js` - passed.
|
||||||
|
- JSON parsing of language files and web manifest - passed.
|
||||||
|
- `bash -n scripts/install.sh` - passed.
|
||||||
|
- Home Assistant icon files generated and verified as 256x256 RGBA PNG.
|
||||||
|
- Rust compilation was not executed because this execution environment does not contain `cargo`/Rust toolchain. Backend changes were therefore statically reviewed but require the normal project CI/build host to run `cargo test`/`cargo build --release` before deployment.
|
||||||
|
|||||||
+24
-18
@@ -1,16 +1,18 @@
|
|||||||
e027a3bf37a7917e249ebffb980d43b5e18c70e5931113271975cbeda81a88bd ./.env.example
|
623bd769512ef75f51e9bf7fb685d4d59ec84d7ccc75c826fe72c5538be737c5 ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
52c2c492893ce42e68dac763533a5070170c21b18d67b302a4e1380a4d4cc4ab ./BUILD_REPORT.md
|
b26651d11ad5156406250dd7eb3061c5d47fa0c999a6899ea13a97aa6f295178 ./BUILD_REPORT.md
|
||||||
4202057467ffdc3f53ef250c3e7852a929be788bb0af03fbd2366a57ce42b79e ./Cargo.lock
|
4202057467ffdc3f53ef250c3e7852a929be788bb0af03fbd2366a57ce42b79e ./Cargo.lock
|
||||||
9edc867d716d9d8ce2c54cdf17e16628f9c2b338ee36f88e0bb0fcb4f7884b57 ./Cargo.toml
|
9edc867d716d9d8ce2c54cdf17e16628f9c2b338ee36f88e0bb0fcb4f7884b57 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
44675b0d5d9e6e89dd0bae8eff3ab76a190a3b1bdd6507e7402957bbd92cac36 ./README.md
|
44675b0d5d9e6e89dd0bae8eff3ab76a190a3b1bdd6507e7402957bbd92cac36 ./README.md
|
||||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||||
674f8cf1ab9f1a49188e20b8e3977f1e2b7e9b8ef975315266730c44a55bbb48 ./docs/API.md
|
674f8cf1ab9f1a49188e20b8e3977f1e2b7e9b8ef975315266730c44a55bbb48 ./docs/API.md
|
||||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||||
47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md
|
47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md
|
||||||
|
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
|
||||||
|
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
|
||||||
925ffa324b1b2cfad7ca40354e8255548f70627f95c4bb345b957d3f7e53694c ./home-assistant/README.md
|
925ffa324b1b2cfad7ca40354e8255548f70627f95c4bb345b957d3f7e53694c ./home-assistant/README.md
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py
|
b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py
|
||||||
@@ -19,6 +21,8 @@ b0314469ddb7c69ee4a96c677815af30cb97713e93911a05597f2fdc3d10f489 ./home-assista
|
|||||||
071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py
|
071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py
|
||||||
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
|
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||||
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||||
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||||
f79cc14c65131fa86a5bf41d55f49b60a3aa71a6d15de98027de830e3b11b2a1 ./home-assistant/custom_components/gree_controller/manifest.json
|
f79cc14c65131fa86a5bf41d55f49b60a3aa71a6d15de98027de830e3b11b2a1 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||||
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
|
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
|
||||||
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
|
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||||
@@ -27,38 +31,40 @@ d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assista
|
|||||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
eb5370fbee1a8edc11a536ff0e68dba2152767734feb2976fef3cb7cc8c885f4 ./lang/en.json
|
ab2f7193ad789a286f0a0ba23bd53b9816eb469dcfdf0860033543760b942bf3 ./lang/en.json
|
||||||
9e4f43dd809f2d92aca8ce6813e843dbf7e2f38adcfd9cdae584a94e22197fa3 ./lang/pl.json
|
510bd3303d99cea8fbf10261dfbf0351da136d8844b7225f746be8b5b187e19e ./lang/pl.json
|
||||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||||
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
|
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
|
||||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
||||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||||
cae0900fbc67e7cea311c4bd334d774f339092e92721c9c2b1570ca91920a53a ./scripts/install.sh
|
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
|
||||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||||
ca63498714f3e154fd98c0c2157ab355bf36fc2208d7b3f82c201e153749fb04 ./src/api.rs
|
87b985ba7095a3b165928fe6d051acf34c34d2b48ce882b2b2c89c486f3f2ad3 ./src/api.rs
|
||||||
31ef3ddbc096e580e9212486683e4db86998322ee87190c61b0cab5ae6bc8fc9 ./src/config.rs
|
0f50b30a0088b6ae8289ecf42be1938cbcd95f672d370cff7e5446e7e1f02b83 ./src/config.rs
|
||||||
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
|
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
|
||||||
9dfea316b39da6ded85cb9c7ec6f6a61e0cd9a51f40469145782d5766c9cf7a4 ./src/engine.rs
|
614bc52f203f2de657266a74fa93933a8b30a4a631b208282c88813687a0db37 ./src/engine.rs
|
||||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||||
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
|
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
|
||||||
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
||||||
1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs
|
31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs
|
||||||
b3f95c0fc9dfb2ef6d3bb75f5b61c434ed004cfa7fee2c8a93ead194afde5a27 ./src/models.rs
|
6be14e8e3286c9ce5bd921ccaa39431bb2d3cd882d2945fa8b17b2f00bf5a8fb ./src/models.rs
|
||||||
|
30e9dd984027405a726c3f281673c28ae4d48c0fb8af823b3c0e4c5706505263 ./src/notifications.rs
|
||||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
37bd46ac7c50d1674463eddcf2ee9c85cdbf8fe0829cef13a3f5fe857d3d0f55 ./src/protocol/gree.rs
|
37bd46ac7c50d1674463eddcf2ee9c85cdbf8fe0829cef13a3f5fe857d3d0f55 ./src/protocol/gree.rs
|
||||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||||
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
|
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
|
||||||
e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
|
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
|
||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
17342e3aa40a55285f5d056502b232e6aeed41a64ff3e1fdf94c0d8278385ffe ./web/app.js
|
ef02fe53df75af12cb957e15b945543eeba937a13154a1fc6c5234ee6f25b435 ./web/app.js
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
1474d31bf8266f6b6dc2dc47225644394259dc6e0afc8e09f0eaddfa93db1d82 ./web/index.html
|
002c172392a00006a5128fc4cb881b0361a2d3e76298dbd80c0a013f4e1f9dde ./web/index.html
|
||||||
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
|
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||||
b8a3cecbde31f9aa4ed3afb5755c62cf98ad90a12faf0a616835170ee889e252 ./web/styles.css
|
648630cb390be4ef7654053caa4010760e5f963ea9d67ccec4e6b07a5e2545f7 ./web/styles.css
|
||||||
f8bda0083c138828a037469d3db189559190d4b893bcff764a6f9bfd9030b265 ./web/sw.js
|
87d25648f24b853c94b39e31e93fe737cae524c74402f287a586d0b5718ca39e ./web/sw.js
|
||||||
|
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fn main() {
|
|||||||
"name": name,
|
"name": name,
|
||||||
"native_name": native_name,
|
"native_name": native_name,
|
||||||
"locale": locale,
|
"locale": locale,
|
||||||
"path": format!("/lang/{code}.json")
|
"path": format!("lang/{code}.json")
|
||||||
}));
|
}));
|
||||||
assets.push((code.to_owned(), path));
|
assets.push((code.to_owned(), path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Reverse proxy
|
||||||
|
|
||||||
|
The web UI supports HTTPS/WSS and sub-path deployments. The server honors `GREE_CONTROLLER_BASE_PATH` and also understands `X-Forwarded-Prefix` when a proxy strips the prefix before forwarding.
|
||||||
|
|
||||||
|
## Root deployment
|
||||||
|
|
||||||
|
Proxy `/` to `http://127.0.0.1:8787` and forward WebSocket upgrade headers. No controller setting is required.
|
||||||
|
|
||||||
|
## Sub-path deployment
|
||||||
|
|
||||||
|
For a public path such as `/gree`, either:
|
||||||
|
|
||||||
|
- set `GREE_CONTROLLER_BASE_PATH=/gree` and proxy `/gree` to the controller without stripping the path, or
|
||||||
|
- strip `/gree` in the proxy and send `X-Forwarded-Prefix: /gree`.
|
||||||
|
|
||||||
|
Static assets, language packs, browser routes, API requests, PWA scope and WebSocket URLs use the effective base path.
|
||||||
|
|
||||||
|
### nginx example (prefix stripped)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /gree/ {
|
||||||
|
proxy_pass http://127.0.0.1:8787/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix /gree;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For an Internet-facing endpoint, add TLS and proxy authentication. If the application token is enabled, avoid access-log formats that record query strings because the browser WebSocket connection includes the token in its URL.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Security notes
|
||||||
|
|
||||||
|
The controller is designed primarily for a trusted LAN. This release adds safe defaults without forcing authentication on existing installations.
|
||||||
|
|
||||||
|
## Implemented safeguards
|
||||||
|
|
||||||
|
- `GREE_CONTROLLER_APP_TOKEN=` remains supported. Authentication is optional and existing installations are compatible.
|
||||||
|
- Permissive CORS was removed. The web UI and API are same-origin by default.
|
||||||
|
- Responses include `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy` and a restrictive `Permissions-Policy`; API responses are marked `no-store`.
|
||||||
|
- Runtime secrets for Home Assistant, InfluxDB and notifications are stored server-side and never returned to the browser. Empty secret fields keep the previously stored value.
|
||||||
|
- Slack and Discord notification webhooks require HTTPS and are restricted to the official webhook hosts. Redirect following is disabled for these outbound webhook requests. Pushover uses its fixed official API endpoint.
|
||||||
|
- API access tokens are stored hashed. Administrator token behavior is unchanged for backwards compatibility.
|
||||||
|
- History/event API limits and SQLite indexes bound common read paths. History retention/compaction is configurable.
|
||||||
|
|
||||||
|
## Remaining risks / recommendations
|
||||||
|
|
||||||
|
1. With an empty application token, every client that can reach the controller HTTP port can operate the air conditioners and change configuration. Keep port 8787 on a trusted VLAN or put authentication in the reverse proxy.
|
||||||
|
2. The WebSocket administrator token is still passed in its query string because browser WebSocket APIs cannot set an Authorization header. Avoid logging query strings at the proxy and prefer HTTPS/WSS when a token is enabled.
|
||||||
|
3. The application does not terminate TLS. Use a reverse proxy or VPN for traffic crossing an untrusted network.
|
||||||
|
4. Home Assistant `allow_invalid_tls` should be used only with a known local instance; keep it disabled otherwise.
|
||||||
|
5. Configuration exports contain device keys and may contain integration secrets. Treat backup files as secrets.
|
||||||
|
6. There is no per-client API rate limiter. For exposure beyond a trusted LAN, configure rate limiting at the reverse proxy.
|
||||||
|
7. No CSRF token is used. Same-origin operation, removal of permissive CORS and bearer-token authentication reduce the risk, but an authenticated public deployment should also use proxy-level origin/access controls.
|
||||||
|
|
||||||
|
## Suggested reverse-proxy hardening
|
||||||
|
|
||||||
|
- TLS only; redirect HTTP to HTTPS.
|
||||||
|
- Do not log URL query strings if administrator authentication is enabled.
|
||||||
|
- Pass WebSocket upgrades for `/ws`.
|
||||||
|
- Add authentication at the proxy when `GREE_CONTROLLER_APP_TOKEN` is empty and the service is reachable outside the trusted LAN.
|
||||||
|
- Limit request body sizes and add basic request-rate limiting.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
+32
-2
@@ -390,7 +390,7 @@
|
|||||||
"settings.debugOverlay": "Show debug window on every page",
|
"settings.debugOverlay": "Show debug window on every page",
|
||||||
"settings.debugGreeFrames": "Include GREE protocol frames",
|
"settings.debugGreeFrames": "Include GREE protocol frames",
|
||||||
"settings.backup": "Configuration backup",
|
"settings.backup": "Configuration backup",
|
||||||
"settings.backupHint": "Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.",
|
"settings.backupHint": "Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant, InfluxDB and notification secrets; metrics and API access tokens are not included.",
|
||||||
"settings.export": "Export settings",
|
"settings.export": "Export settings",
|
||||||
"settings.import": "Import settings",
|
"settings.import": "Import settings",
|
||||||
"settings.importConfirm": "Replace the current application configuration with this file? Existing metrics and API access tokens will be kept.",
|
"settings.importConfirm": "Replace the current application configuration with this file? Existing metrics and API access tokens will be kept.",
|
||||||
@@ -498,6 +498,36 @@
|
|||||||
"night.saveHint": "Save night-mode changes.",
|
"night.saveHint": "Save night-mode changes.",
|
||||||
"homeAssistant.eyebrow": "Integration",
|
"homeAssistant.eyebrow": "Integration",
|
||||||
"homeAssistant.description": "Connection, temperature sensors, friendly aliases and access tokens for the Home Assistant integration.",
|
"homeAssistant.description": "Connection, temperature sensors, friendly aliases and access tokens for the Home Assistant integration.",
|
||||||
"homeAssistant.saveHint": "Save Home Assistant and sensor changes."
|
"homeAssistant.saveHint": "Save Home Assistant and sensor changes.",
|
||||||
|
"notifications.title": "Notifications",
|
||||||
|
"notifications.hint": "Send important events or only problems/anomalies.",
|
||||||
|
"notifications.enable": "Enable notifications",
|
||||||
|
"notifications.mode": "Mode",
|
||||||
|
"notifications.problems": "Errors and anomalies only",
|
||||||
|
"notifications.important": "Important events + problems",
|
||||||
|
"notifications.provider": "Provider",
|
||||||
|
"notifications.pushoverToken": "Pushover application token",
|
||||||
|
"notifications.pushoverUser": "Pushover user/group key",
|
||||||
|
"notifications.slackWebhook": "Slack incoming webhook",
|
||||||
|
"notifications.discordWebhook": "Discord webhook",
|
||||||
|
"notifications.cooldown": "Cooldown (s)",
|
||||||
|
"notifications.failureThreshold": "Communication failures before alert",
|
||||||
|
"notifications.targetTimeout": "Target not reached timeout (min)",
|
||||||
|
"notifications.test": "Send test notification",
|
||||||
|
"zones.copyFrom": "Copy thermostat settings from…",
|
||||||
|
"zones.copySettings": "Copy settings",
|
||||||
|
"automations.preset": "Preset…",
|
||||||
|
"automations.presetHot": "High temperature cooling",
|
||||||
|
"automations.presetCold": "Low temperature heating",
|
||||||
|
"automations.presetMorning": "Morning comfort",
|
||||||
|
"automations.presetNightOff": "Night power off",
|
||||||
|
"actions.apply": "Apply",
|
||||||
|
"logs.level": "Level",
|
||||||
|
"logs.category": "Category",
|
||||||
|
"logs.all": "All",
|
||||||
|
"logs.errors": "Errors",
|
||||||
|
"logs.warnings": "Warnings",
|
||||||
|
"logs.integrations": "Integrations",
|
||||||
|
"logs.systemOther": "System/other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-2
@@ -390,7 +390,7 @@
|
|||||||
"settings.debugOverlay": "Pokazuj okno debug na każdej podstronie",
|
"settings.debugOverlay": "Pokazuj okno debug na każdej podstronie",
|
||||||
"settings.debugGreeFrames": "Dołącz ramki protokołu GREE",
|
"settings.debugGreeFrames": "Dołącz ramki protokołu GREE",
|
||||||
"settings.backup": "Kopia konfiguracji",
|
"settings.backup": "Kopia konfiguracji",
|
||||||
"settings.backupHint": "Eksport/import konfiguracji aplikacji. Plik eksportu może zawierać klucze urządzeń GREE oraz sekrety Home Assistant i InfluxDB; metryki i tokeny dostępu API nie są eksportowane.",
|
"settings.backupHint": "Eksport/import konfiguracji aplikacji. Plik eksportu może zawierać klucze urządzeń GREE oraz sekrety Home Assistant, InfluxDB i powiadomień; metryki i tokeny dostępu API nie są eksportowane.",
|
||||||
"settings.export": "Eksportuj ustawienia",
|
"settings.export": "Eksportuj ustawienia",
|
||||||
"settings.import": "Importuj ustawienia",
|
"settings.import": "Importuj ustawienia",
|
||||||
"settings.importConfirm": "Zastąpić bieżącą konfigurację aplikacji tym plikiem? Istniejące metryki i tokeny dostępu API zostaną zachowane.",
|
"settings.importConfirm": "Zastąpić bieżącą konfigurację aplikacji tym plikiem? Istniejące metryki i tokeny dostępu API zostaną zachowane.",
|
||||||
@@ -498,6 +498,36 @@
|
|||||||
"night.saveHint": "Zapisz zmiany trybu nocnego.",
|
"night.saveHint": "Zapisz zmiany trybu nocnego.",
|
||||||
"homeAssistant.eyebrow": "Integracja",
|
"homeAssistant.eyebrow": "Integracja",
|
||||||
"homeAssistant.description": "Połączenie, czujniki temperatury, przyjazne aliasy i tokeny dostępu dla integracji Home Assistant.",
|
"homeAssistant.description": "Połączenie, czujniki temperatury, przyjazne aliasy i tokeny dostępu dla integracji Home Assistant.",
|
||||||
"homeAssistant.saveHint": "Zapisz ustawienia Home Assistant i czujników."
|
"homeAssistant.saveHint": "Zapisz ustawienia Home Assistant i czujników.",
|
||||||
|
"notifications.title": "Powiadomienia",
|
||||||
|
"notifications.hint": "Wysyłaj ważne zdarzenia albo tylko błędy i anomalie.",
|
||||||
|
"notifications.enable": "Włącz powiadomienia",
|
||||||
|
"notifications.mode": "Tryb",
|
||||||
|
"notifications.problems": "Tylko błędy i anomalie",
|
||||||
|
"notifications.important": "Ważne zdarzenia + problemy",
|
||||||
|
"notifications.provider": "Dostawca",
|
||||||
|
"notifications.pushoverToken": "Token aplikacji Pushover",
|
||||||
|
"notifications.pushoverUser": "Klucz użytkownika/grupy Pushover",
|
||||||
|
"notifications.slackWebhook": "Webhook przychodzący Slack",
|
||||||
|
"notifications.discordWebhook": "Webhook Discord",
|
||||||
|
"notifications.cooldown": "Odstęp między powiadomieniami (s)",
|
||||||
|
"notifications.failureThreshold": "Liczba błędów komunikacji przed alarmem",
|
||||||
|
"notifications.targetTimeout": "Limit czasu na osiągnięcie temperatury (min)",
|
||||||
|
"notifications.test": "Wyślij powiadomienie testowe",
|
||||||
|
"zones.copyFrom": "Kopiuj ustawienia termostatu z…",
|
||||||
|
"zones.copySettings": "Kopiuj ustawienia",
|
||||||
|
"automations.preset": "Preset…",
|
||||||
|
"automations.presetHot": "Chłodzenie przy wysokiej temperaturze",
|
||||||
|
"automations.presetCold": "Grzanie przy niskiej temperaturze",
|
||||||
|
"automations.presetMorning": "Komfort rano",
|
||||||
|
"automations.presetNightOff": "Wyłączanie na noc",
|
||||||
|
"actions.apply": "Zastosuj",
|
||||||
|
"logs.level": "Poziom",
|
||||||
|
"logs.category": "Kategoria",
|
||||||
|
"logs.all": "Wszystkie",
|
||||||
|
"logs.errors": "Błędy",
|
||||||
|
"logs.warnings": "Ostrzeżenia",
|
||||||
|
"logs.integrations": "Integracje",
|
||||||
|
"logs.systemOther": "System/inne"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -93,10 +93,13 @@ read_env_value() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
health_url() {
|
health_url() {
|
||||||
local bind port
|
local bind port base
|
||||||
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
|
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
|
||||||
port="${bind##*:}"
|
port="${bind##*:}"
|
||||||
printf 'http://127.0.0.1:%s/api/health' "$port"
|
base="$(read_env_value GREE_CONTROLLER_BASE_PATH '')"
|
||||||
|
base="/${base#/}"; base="${base%/}"
|
||||||
|
[[ "$base" == "/" ]] && base=""
|
||||||
|
printf 'http://127.0.0.1:%s%s/api/health' "$port" "$base"
|
||||||
}
|
}
|
||||||
|
|
||||||
database_path() {
|
database_path() {
|
||||||
@@ -109,11 +112,14 @@ database_path() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
panel_url() {
|
panel_url() {
|
||||||
local bind port ip
|
local bind port ip base
|
||||||
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
|
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
|
||||||
port="${bind##*:}"
|
port="${bind##*:}"
|
||||||
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
printf 'http://%s:%s' "${ip:-LXC_ADDRESS}" "$port"
|
base="$(read_env_value GREE_CONTROLLER_BASE_PATH '')"
|
||||||
|
base="/${base#/}"; base="${base%/}"
|
||||||
|
[[ "$base" == "/" ]] && base=""
|
||||||
|
printf 'http://%s:%s%s/' "${ip:-LXC_ADDRESS}" "$port" "$base"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_health() {
|
wait_for_health() {
|
||||||
|
|||||||
+4
-18
@@ -64,31 +64,17 @@ install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY"
|
|||||||
install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE"
|
install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE"
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
|
|
||||||
cat > "$ENV_FILE" <<ENV
|
cat > "$ENV_FILE" <<ENV
|
||||||
GREE_CONTROLLER_BIND=0.0.0.0:8787
|
GREE_CONTROLLER_BIND=0.0.0.0:8787
|
||||||
GREE_CONTROLLER_DATABASE=$DATA_DIR/gree-controller.db
|
GREE_CONTROLLER_DATABASE=$DATA_DIR/gree-controller.db
|
||||||
GREE_CONTROLLER_APP_TOKEN=$token
|
# Empty token intentionally enables trusted-LAN mode without login.
|
||||||
|
GREE_CONTROLLER_APP_TOKEN=
|
||||||
GREE_CONTROLLER_SIMULATE=false
|
GREE_CONTROLLER_SIMULATE=false
|
||||||
GREE_CONTROLLER_AUTO_SEED=false
|
GREE_CONTROLLER_AUTO_SEED=false
|
||||||
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
|
RUST_LOG=gree_controller=info,tower_http=info
|
||||||
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
|
|
||||||
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
|
|
||||||
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
|
|
||||||
GREE_CONTROLLER_GREE_INTERFACE=
|
|
||||||
GREE_CONTROLLER_ID=gree-controller
|
|
||||||
GREE_CONTROLLER_HOUSE_MODE=cool
|
|
||||||
GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED=true
|
|
||||||
RUST_LOG=info,tower_http=info
|
|
||||||
HA_URL=
|
|
||||||
HA_TOKEN=
|
|
||||||
HA_ENTITY_ID=
|
|
||||||
HA_OUTDOOR_ENTITY_ID=
|
|
||||||
HA_ALLOW_INVALID_TLS=false
|
|
||||||
ENV
|
ENV
|
||||||
chmod 0600 "$ENV_FILE"
|
chmod 0600 "$ENV_FILE"
|
||||||
say "Generated administrator token and saved it to $ENV_FILE"
|
say "Created minimal configuration in $ENV_FILE (authentication disabled by default)"
|
||||||
printf 'Administrator token: %s\n' "$token"
|
|
||||||
else
|
else
|
||||||
say "Preserving existing $ENV_FILE"
|
say "Preserving existing $ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+96
-8
@@ -2,9 +2,9 @@ use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
|
|||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||||
http::{header, HeaderValue, StatusCode},
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::{Html, Response},
|
response::{Redirect, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
@@ -15,20 +15,22 @@ use rand::{rngs::OsRng, RngCore};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::{
|
use crate::{
|
||||||
engine,
|
engine,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
home_assistant,
|
home_assistant,
|
||||||
influxdb,
|
influxdb,
|
||||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
notifications,
|
||||||
|
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||||
protocol::merge_discovered,
|
protocol::merge_discovered,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||||
const APP_JS: &str = include_str!("../web/app.js");
|
const APP_JS: &str = include_str!("../web/app.js");
|
||||||
|
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
|
||||||
const STYLES_CSS: &str = include_str!("../web/styles.css");
|
const STYLES_CSS: &str = include_str!("../web/styles.css");
|
||||||
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
|
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
|
||||||
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
||||||
@@ -67,6 +69,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||||
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
||||||
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
||||||
|
.route("/api/integrations/notifications/test", post(test_notifications))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||||
|
|
||||||
let home_assistant_api = Router::new()
|
let home_assistant_api = Router::new()
|
||||||
@@ -76,12 +79,13 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
|
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||||
|
|
||||||
Router::new()
|
let app = Router::new()
|
||||||
.route("/api/health", get(health))
|
.route("/api/health", get(health))
|
||||||
.route("/ws", get(websocket))
|
.route("/ws", get(websocket))
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/index.html", get(index))
|
.route("/index.html", get(index))
|
||||||
.route("/app.js", get(app_js))
|
.route("/app.js", get(app_js))
|
||||||
|
.route("/theme-init.js", get(theme_init_js))
|
||||||
.route("/styles.css", get(styles_css))
|
.route("/styles.css", get(styles_css))
|
||||||
.route("/manifest.webmanifest", get(manifest))
|
.route("/manifest.webmanifest", get(manifest))
|
||||||
.route("/sw.js", get(service_worker))
|
.route("/sw.js", get(service_worker))
|
||||||
@@ -91,9 +95,25 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.merge(protected)
|
.merge(protected)
|
||||||
.merge(home_assistant_api)
|
.merge(home_assistant_api)
|
||||||
.fallback(index)
|
.fallback(index)
|
||||||
|
;
|
||||||
|
|
||||||
|
let app = if state.config.base_path.is_empty() {
|
||||||
|
app
|
||||||
|
} else {
|
||||||
|
let base = state.config.base_path.clone();
|
||||||
|
let redirect_to = format!("{base}/");
|
||||||
|
Router::new()
|
||||||
|
.route(&base, get(move || {
|
||||||
|
let redirect_to = redirect_to.clone();
|
||||||
|
async move { Redirect::permanent(&redirect_to) }
|
||||||
|
}))
|
||||||
|
.nest(&base, app)
|
||||||
|
};
|
||||||
|
|
||||||
|
app
|
||||||
.layer(CompressionLayer::new())
|
.layer(CompressionLayer::new())
|
||||||
.layer(CorsLayer::permissive())
|
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(middleware::from_fn(security_headers))
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
|
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@@ -466,7 +486,7 @@ impl ZoneInput {
|
|||||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
||||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||||
demand: false, last_action_at: None,
|
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
||||||
created_at, updated_at: Utc::now(),
|
created_at, updated_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,6 +523,8 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
zone.effective_setpoint = existing.effective_setpoint;
|
zone.effective_setpoint = existing.effective_setpoint;
|
||||||
zone.device_setpoint = existing.device_setpoint;
|
zone.device_setpoint = existing.device_setpoint;
|
||||||
zone.demand = existing.demand;
|
zone.demand = existing.demand;
|
||||||
|
zone.demand_since = existing.demand_since;
|
||||||
|
zone.target_alerted_at = existing.target_alerted_at;
|
||||||
zone.last_action_at = existing.last_action_at;
|
zone.last_action_at = existing.last_action_at;
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||||
@@ -1129,6 +1151,15 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
|||||||
}
|
}
|
||||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||||
|
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
|
||||||
|
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
|
||||||
|
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
|
||||||
|
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
|
||||||
|
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
|
||||||
|
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
|
||||||
|
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
|
||||||
|
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
|
||||||
|
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
|
||||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||||
normalize_sensor_aliases(&mut input);
|
normalize_sensor_aliases(&mut input);
|
||||||
@@ -1316,6 +1347,29 @@ async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<Ha
|
|||||||
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
|
||||||
|
let old = state.settings.read().await.notifications.clone();
|
||||||
|
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
||||||
|
if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; }
|
||||||
|
if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; }
|
||||||
|
if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; }
|
||||||
|
notifications::test(&state, input).await.map_err(AppError::Device)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn security_headers(request: Request, next: Next) -> Response {
|
||||||
|
let is_api = request.uri().path().contains("/api/");
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"));
|
||||||
|
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
|
||||||
|
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin"));
|
||||||
|
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
|
||||||
|
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"));
|
||||||
|
if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); }
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
fn public_settings(settings: &RuntimeSettings) -> Value {
|
fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"controller_id": settings.controller_id,
|
"controller_id": settings.controller_id,
|
||||||
@@ -1333,6 +1387,21 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
|||||||
"suppress_device_beep": settings.suppress_device_beep,
|
"suppress_device_beep": settings.suppress_device_beep,
|
||||||
"debug": settings.debug,
|
"debug": settings.debug,
|
||||||
"night_mode": settings.night_mode,
|
"night_mode": settings.night_mode,
|
||||||
|
"notifications": {
|
||||||
|
"enabled": settings.notifications.enabled,
|
||||||
|
"mode": settings.notifications.mode,
|
||||||
|
"provider": settings.notifications.provider,
|
||||||
|
"pushover_app_token": "",
|
||||||
|
"pushover_user_key": "",
|
||||||
|
"pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(),
|
||||||
|
"slack_webhook_url": "",
|
||||||
|
"slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(),
|
||||||
|
"discord_webhook_url": "",
|
||||||
|
"discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(),
|
||||||
|
"cooldown_seconds": settings.notifications.cooldown_seconds,
|
||||||
|
"communication_failure_threshold": settings.notifications.communication_failure_threshold,
|
||||||
|
"target_timeout_minutes": settings.notifications.target_timeout_minutes,
|
||||||
|
},
|
||||||
"influxdb": {
|
"influxdb": {
|
||||||
"enabled": settings.influxdb.enabled,
|
"enabled": settings.influxdb.enabled,
|
||||||
"version": settings.influxdb.version,
|
"version": settings.influxdb.version,
|
||||||
@@ -1399,8 +1468,27 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
|
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||||
|
let base = if !state.config.base_path.is_empty() {
|
||||||
|
state.config.base_path.clone()
|
||||||
|
} else {
|
||||||
|
forwarded_prefix(&headers).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base);
|
||||||
|
let mut response = Response::new(Body::from(body));
|
||||||
|
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||||
|
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
|
||||||
|
let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim();
|
||||||
|
if raw.is_empty() || raw == "/" { return Some(String::new()); }
|
||||||
|
if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; }
|
||||||
|
Some(format!("/{}", raw.trim_matches('/')))
|
||||||
|
}
|
||||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||||
|
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=86400") }
|
||||||
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
|
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
|
||||||
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
|
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
|
||||||
async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
|
async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
|
||||||
|
|||||||
+15
-2
@@ -1,7 +1,7 @@
|
|||||||
use std::{env, net::SocketAddr, path::PathBuf};
|
use std::{env, net::SocketAddr, path::PathBuf};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings};
|
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
@@ -12,6 +12,8 @@ pub struct Config {
|
|||||||
pub database: PathBuf,
|
pub database: PathBuf,
|
||||||
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
|
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
|
||||||
pub app_token: String,
|
pub app_token: String,
|
||||||
|
#[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")]
|
||||||
|
pub base_path: String,
|
||||||
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
|
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
|
||||||
pub simulate: bool,
|
pub simulate: bool,
|
||||||
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
|
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
|
||||||
@@ -33,7 +35,8 @@ pub struct Config {
|
|||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let config = Self::parse();
|
let mut config = Self::parse();
|
||||||
|
config.base_path = normalize_base_path(&config.base_path)?;
|
||||||
if let Some(parent) = config.database.parent() {
|
if let Some(parent) = config.database.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
|
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
|
||||||
@@ -61,6 +64,7 @@ impl Config {
|
|||||||
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
||||||
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
||||||
},
|
},
|
||||||
|
notifications: NotificationSettings::default(),
|
||||||
night_mode: NightModeSettings {
|
night_mode: NightModeSettings {
|
||||||
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
|
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
|
||||||
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
|
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
|
||||||
@@ -128,6 +132,15 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_base_path(value: &str) -> Result<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() || value == "/" { return Ok(String::new()); }
|
||||||
|
if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) {
|
||||||
|
anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment");
|
||||||
|
}
|
||||||
|
Ok(format!("/{}", value.trim_matches('/')))
|
||||||
|
}
|
||||||
|
|
||||||
fn env_bool(name: &str) -> Option<bool> {
|
fn env_bool(name: &str) -> Option<bool> {
|
||||||
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-1
@@ -198,6 +198,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
|||||||
simulate_tick(device);
|
simulate_tick(device);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let previous_failures = device.communication_failures;
|
||||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||||
match state.gree.bind(device).await {
|
match state.gree.bind(device).await {
|
||||||
Ok(bound) => {
|
Ok(bound) => {
|
||||||
@@ -207,6 +208,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
record_poll_failure(device, &err.to_string());
|
record_poll_failure(device, &err.to_string());
|
||||||
|
log_poll_health_transition(state, device, previous_failures).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +227,18 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
|||||||
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log_poll_health_transition(state, device, previous_failures).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u32) {
|
||||||
|
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
|
||||||
|
if device.communication_failures >= threshold && previous_failures < threshold {
|
||||||
|
state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({
|
||||||
|
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
|
||||||
|
}));
|
||||||
|
} else if device.communication_failures == 0 && previous_failures >= threshold {
|
||||||
|
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn simulate_tick(device: &mut Device) {
|
fn simulate_tick(device: &mut Device) {
|
||||||
@@ -472,6 +486,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
else { zone.demand }
|
else { zone.demand }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if zone.demand && !previous_demand {
|
||||||
|
zone.demand_since = Some(Utc::now());
|
||||||
|
zone.target_alerted_at = None;
|
||||||
|
} else if !zone.demand {
|
||||||
|
zone.demand_since = None;
|
||||||
|
zone.target_alerted_at = None;
|
||||||
|
}
|
||||||
|
if zone.demand && zone.target_alerted_at.is_none() {
|
||||||
|
let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64;
|
||||||
|
if let Some(since) = zone.demand_since {
|
||||||
|
if (Utc::now() - since).num_minutes() >= timeout_minutes {
|
||||||
|
state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({
|
||||||
|
"zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes
|
||||||
|
}));
|
||||||
|
zone.target_alerted_at = Some(Utc::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||||
@@ -1117,7 +1149,7 @@ mod tests {
|
|||||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||||
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod error;
|
|||||||
mod home_assistant;
|
mod home_assistant;
|
||||||
mod influxdb;
|
mod influxdb;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod notifications;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
mod queries;
|
mod queries;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ pub struct Zone {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub demand: bool,
|
pub demand: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub demand_since: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_alerted_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
pub last_action_at: Option<DateTime<Utc>>,
|
pub last_action_at: Option<DateTime<Utc>>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
@@ -512,6 +516,52 @@ impl Default for InfluxDbSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NotificationSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// problems = warnings/errors/anomalies, important = problems plus important state changes.
|
||||||
|
#[serde(default = "default_notification_mode")]
|
||||||
|
pub mode: String,
|
||||||
|
/// pushover, slack, discord
|
||||||
|
#[serde(default = "default_notification_provider")]
|
||||||
|
pub provider: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pushover_app_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pushover_user_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub slack_webhook_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub discord_webhook_url: String,
|
||||||
|
#[serde(default = "default_notification_cooldown")]
|
||||||
|
pub cooldown_seconds: u64,
|
||||||
|
#[serde(default = "default_notification_failure_threshold")]
|
||||||
|
pub communication_failure_threshold: u32,
|
||||||
|
#[serde(default = "default_notification_target_timeout")]
|
||||||
|
pub target_timeout_minutes: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_notification_mode() -> String { "problems".into() }
|
||||||
|
fn default_notification_provider() -> String { "pushover".into() }
|
||||||
|
fn default_notification_cooldown() -> u64 { 300 }
|
||||||
|
fn default_notification_failure_threshold() -> u32 { 3 }
|
||||||
|
fn default_notification_target_timeout() -> u32 { 60 }
|
||||||
|
|
||||||
|
impl Default for NotificationSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false, mode: default_notification_mode(), provider: default_notification_provider(),
|
||||||
|
pushover_app_token: String::new(), pushover_user_key: String::new(),
|
||||||
|
slack_webhook_url: String::new(), discord_webhook_url: String::new(),
|
||||||
|
cooldown_seconds: default_notification_cooldown(),
|
||||||
|
communication_failure_threshold: default_notification_failure_threshold(),
|
||||||
|
target_timeout_minutes: default_notification_target_timeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct DebugSettings {
|
pub struct DebugSettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -661,6 +711,8 @@ pub struct RuntimeSettings {
|
|||||||
pub debug: DebugSettings,
|
pub debug: DebugSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub night_mode: NightModeSettings,
|
pub night_mode: NightModeSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notifications: NotificationSettings,
|
||||||
pub home_assistant: HomeAssistantSettings,
|
pub home_assistant: HomeAssistantSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use crate::{models::NotificationSettings, state::AppState};
|
||||||
|
|
||||||
|
static LAST_SENT: OnceLock<Mutex<HashMap<String, i64>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn important_kind(kind: &str) -> bool {
|
||||||
|
matches!(kind,
|
||||||
|
"device.offline" | "device.recovered" | "automation.fired" | "automation.error" |
|
||||||
|
"zone.target_timeout" | "zone.action_error" | "house.mode" | "house.preset" |
|
||||||
|
"device.communication_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool {
|
||||||
|
if !cfg.enabled { return false; }
|
||||||
|
if level == "error" || level == "warn" { return true; }
|
||||||
|
cfg.mode == "important" && important_kind(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String {
|
||||||
|
let entity = metadata.get("device_id").or_else(|| metadata.get("zone_id")).or_else(|| metadata.get("automation_id"))
|
||||||
|
.and_then(Value::as_str).unwrap_or("global");
|
||||||
|
format!("{}:{}:{}", cfg.provider, kind, entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bool {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let key = cooldown_key(cfg, kind, metadata);
|
||||||
|
let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let Ok(mut map) = map.lock() else { return false; };
|
||||||
|
if let Some(last) = map.get(&key) {
|
||||||
|
if now - *last < cfg.cooldown_seconds.max(30) as i64 { return false; }
|
||||||
|
}
|
||||||
|
map.insert(key, now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch(state: AppState, level: String, kind: String, message: String, metadata: Value) {
|
||||||
|
let cfg = state.settings.read().await.notifications.clone();
|
||||||
|
if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { return; }
|
||||||
|
let title = format!("GREE Controller - {}", if level == "error" { "error" } else if level == "warn" { "warning" } else { "event" });
|
||||||
|
let result = match cfg.provider.as_str() {
|
||||||
|
"pushover" => send_pushover(&state, &cfg, &title, &message).await,
|
||||||
|
"slack" => send_webhook(&state, &cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)})).await,
|
||||||
|
"discord" => send_webhook(&state, &cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)})).await,
|
||||||
|
_ => Err("unsupported notification provider".into()),
|
||||||
|
};
|
||||||
|
if let Err(err) = result {
|
||||||
|
tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_pushover(state: &AppState, cfg: &NotificationSettings, title: &str, message: &str) -> Result<(), String> {
|
||||||
|
if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { return Err("Pushover credentials are incomplete".into()); }
|
||||||
|
let response = state.http.post("https://api.pushover.net/1/messages.json")
|
||||||
|
.form(&[("token", cfg.pushover_app_token.as_str()), ("user", cfg.pushover_user_key.as_str()), ("title", title), ("message", message)])
|
||||||
|
.send().await.map_err(|e| e.to_string())?;
|
||||||
|
if response.status().is_success() { Ok(()) } else { Err(format!("Pushover HTTP {}", response.status())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_webhook(state: &AppState, url: &str, body: Value) -> Result<(), String> {
|
||||||
|
let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?;
|
||||||
|
if parsed.scheme() != "https" { return Err("webhook URL must use HTTPS".into()); }
|
||||||
|
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||||
|
let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com";
|
||||||
|
if !allowed { return Err("webhook host is not supported".into()); }
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build().map_err(|e| e.to_string())?;
|
||||||
|
let response = client.post(parsed).json(&body).send().await.map_err(|e| e.to_string())?;
|
||||||
|
if response.status().is_success() { Ok(()) } else { Err(format!("webhook HTTP {}", response.status())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> {
|
||||||
|
match cfg.provider.as_str() {
|
||||||
|
"pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await,
|
||||||
|
"slack" => send_webhook(state, &cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"})).await,
|
||||||
|
"discord" => send_webhook(state, &cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"})).await,
|
||||||
|
_ => Err("unsupported notification provider".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,5 +36,12 @@ impl AppState {
|
|||||||
"message": message,
|
"message": message,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
}));
|
}));
|
||||||
|
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||||
|
let state = self.clone();
|
||||||
|
let level = level.to_string();
|
||||||
|
let kind = kind.to_string();
|
||||||
|
let message = message.to_string();
|
||||||
|
handle.spawn(async move { crate::notifications::dispatch(state, level, kind, message, metadata).await; });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-23
@@ -1,11 +1,18 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const APP_BASE = (() => {
|
||||||
|
const src = document.currentScript?.src || '';
|
||||||
|
try { const path = new URL(src, location.href).pathname; return path.endsWith('/app.js') ? path.slice(0, -7).replace(/\/$/, '') : ''; } catch (_) { return ''; }
|
||||||
|
})();
|
||||||
|
const withBase = path => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
const parseDecimal = value => { const normalized = String(value ?? '').trim().replace(',', '.'); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : NaN; };
|
||||||
|
|
||||||
const getCookie = name => {
|
const getCookie = name => {
|
||||||
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
|
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
|
||||||
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
|
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
|
||||||
};
|
};
|
||||||
const setCookie = (name, value) => {
|
const setCookie = (name, value) => {
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=/; SameSite=Lax`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LANGUAGE = 'en';
|
const DEFAULT_LANGUAGE = 'en';
|
||||||
@@ -71,7 +78,7 @@ function applyTranslations() {
|
|||||||
$$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); });
|
$$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); });
|
||||||
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
|
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
|
||||||
$('#languageSelect').value = app.language;
|
$('#languageSelect').value = app.language;
|
||||||
$('#themeSelect').value = app.theme;
|
const themeSelect = $('#themeSelect'); if (themeSelect) { const icons={system:'◐',light:'☀',dark:'☾'}; [...themeSelect.options].forEach(o => { const key=`theme.${o.value}`; o.textContent=`${icons[o.value]||'◐'} ${tr(key)}`; }); themeSelect.value=app.theme; }
|
||||||
updateConnectionIndicator(app.connectionStatus);
|
updateConnectionIndicator(app.connectionStatus);
|
||||||
renderAll();
|
renderAll();
|
||||||
if (app.currentView === 'logs') loadLogs();
|
if (app.currentView === 'logs') loadLogs();
|
||||||
@@ -90,21 +97,21 @@ function renderLanguageOptions() {
|
|||||||
if (!select) return;
|
if (!select) return;
|
||||||
select.innerHTML = app.languages.map(item => {
|
select.innerHTML = app.languages.map(item => {
|
||||||
const label = item.native_name || item.name || item.code.toUpperCase();
|
const label = item.native_name || item.name || item.code.toUpperCase();
|
||||||
return `<option value="${esc(item.code)}">${esc(label)}</option>`;
|
const flag = ({pl:'🇵🇱',en:'🇬🇧'})[item.code] || '🌐'; return `<option value="${esc(item.code)}">${flag} ${esc(label)}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
select.value = app.language;
|
select.value = app.language;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLanguages() {
|
async function loadLanguages() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/lang/index.json', {cache: 'no-cache'});
|
const response = await fetch(withBase('/lang/index.json'), {cache: 'no-cache'});
|
||||||
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
|
||||||
const manifest = await response.json();
|
const manifest = await response.json();
|
||||||
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
|
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');
|
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 loaded = await Promise.all(languages.map(async item => {
|
||||||
const packResponse = await fetch(item.path || `/lang/${encodeURIComponent(item.code)}.json`, {cache: 'no-cache'});
|
const packResponse = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`), {cache: 'no-cache'});
|
||||||
if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`);
|
if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`);
|
||||||
const pack = await packResponse.json();
|
const pack = await packResponse.json();
|
||||||
return {item, pack};
|
return {item, pack};
|
||||||
@@ -143,7 +150,7 @@ async function api(path, options = {}) {
|
|||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
body = JSON.stringify(body);
|
body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
const response = await fetch(path, {...options, headers, body});
|
const response = await fetch(withBase(path), {...options, headers, body});
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
showTokenDialog();
|
showTokenDialog();
|
||||||
throw new Error(tr('auth.invalid'));
|
throw new Error(tr('auth.invalid'));
|
||||||
@@ -625,6 +632,7 @@ function fillSelects() {
|
|||||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||||
const presetZone = $('#schedulePresetZone');
|
const presetZone = $('#schedulePresetZone');
|
||||||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
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; copyZone.innerHTML='<option value="">Copy thermostat settings from…</option>'+app.zones.map(z=>`<option value="${esc(z.id)}">${esc(z.name)}</option>`).join(''); if([...copyZone.options].some(o=>o.value===current))copyZone.value=current; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAccessTokens() {
|
function renderAccessTokens() {
|
||||||
@@ -691,6 +699,15 @@ function renderSettings() {
|
|||||||
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
|
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_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
|
||||||
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
|
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?'Saved - leave empty to keep':'Application token';
|
||||||
|
form.pushover_user_key.placeholder=n.pushover_configured?'Saved - leave empty to keep':'User/group key';
|
||||||
|
form.slack_webhook_url.placeholder=n.slack_configured?'Saved - leave empty to keep':'https://hooks.slack.com/services/…';
|
||||||
|
form.discord_webhook_url.placeholder=n.discord_configured?'Saved - leave empty to keep':'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;
|
||||||
|
updateNotificationFields();
|
||||||
updateInfluxFields();
|
updateInfluxFields();
|
||||||
renderLogRetention();
|
renderLogRetention();
|
||||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||||
@@ -728,6 +745,9 @@ function updateInfluxFields() {
|
|||||||
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
|
$$('[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 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) {
|
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);
|
if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160);
|
||||||
@@ -781,9 +801,10 @@ function currentHistoryPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateBrowserUrl(path, replace=false) {
|
function updateBrowserUrl(path, replace=false) {
|
||||||
|
const target = `${APP_BASE}${path}` || path;
|
||||||
const current = `${location.pathname}${location.search}`;
|
const current = `${location.pathname}${location.search}`;
|
||||||
if (current === path) return;
|
if (current === target) return;
|
||||||
history[replace ? 'replaceState' : 'pushState']({}, '', path);
|
history[replace ? 'replaceState' : 'pushState']({}, '', target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showView(name, {push=true, scroll=true}={}) {
|
function showView(name, {push=true, scroll=true}={}) {
|
||||||
@@ -804,7 +825,8 @@ function showHistoryTab(tab, {push=true, load=true}={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyRouteFromLocation() {
|
function applyRouteFromLocation() {
|
||||||
const parts = location.pathname.split('/').filter(Boolean);
|
const routePath = APP_BASE && location.pathname.startsWith(APP_BASE) ? location.pathname.slice(APP_BASE.length) : location.pathname;
|
||||||
|
const parts = routePath.split('/').filter(Boolean);
|
||||||
const first = parts[0] || 'dashboard';
|
const first = parts[0] || 'dashboard';
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if (first === 'history') {
|
if (first === 'history') {
|
||||||
@@ -1282,7 +1304,7 @@ async function handleHistoryAction(button) {
|
|||||||
}
|
}
|
||||||
if(action==='copy-chart-link'){
|
if(action==='copy-chart-link'){
|
||||||
if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||||
const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${path}`;
|
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(); }
|
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;
|
toast(tr('history.linkCopied')); return;
|
||||||
}
|
}
|
||||||
@@ -1292,9 +1314,10 @@ async function loadLogs() {
|
|||||||
renderLogRetention();
|
renderLogRetention();
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/events?limit=150');
|
const data = await api('/api/events?limit=150');
|
||||||
const logs = data.events || [];
|
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
|
$('#logList').innerHTML = logs.length
|
||||||
? logs.map(item => `<div class="log-row ${esc(item.level)}"><time>${esc(new Date(item.timestamp).toLocaleTimeString(locale()))}</time><span class="kind">${esc(item.kind)}</span><span class="message">${esc(item.message)}</span></div>`).join('')
|
? 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>`;
|
: `<div class="empty"><strong>${esc(tr('logs.emptyTitle'))}</strong>${esc(tr('logs.emptyText'))}</div>`;
|
||||||
} catch (error) { toast(error.message, true); }
|
} catch (error) { toast(error.message, true); }
|
||||||
}
|
}
|
||||||
@@ -1304,7 +1327,7 @@ function connectWebSocket() {
|
|||||||
clearTimeout(app.wsTimer);
|
clearTimeout(app.wsTimer);
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
|
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws;
|
const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws;
|
||||||
ws.onopen = () => updateConnectionIndicator('connected');
|
ws.onopen = () => updateConnectionIndicator('connected');
|
||||||
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||||
ws.onerror = () => updateConnectionIndicator('connectionError');
|
ws.onerror = () => updateConnectionIndicator('connectionError');
|
||||||
@@ -1404,9 +1427,17 @@ $('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHi
|
|||||||
$('#logsRefresh').addEventListener('click', loadLogs);
|
$('#logsRefresh').addEventListener('click', loadLogs);
|
||||||
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
||||||
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
|
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
|
||||||
|
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
|
||||||
|
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
||||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||||
|
|
||||||
|
$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const saved=app.settings?.notifications||{}; 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)}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast('Test notification sent');}catch(e){toast(e.message,true);} });
|
||||||
|
|
||||||
|
$('#copyZoneSettings')?.addEventListener('click',()=>{ const source=app.zones.find(z=>z.id===$('#copyZoneSource')?.value); if(!source)return; const f=$('#zoneForm'); ['setpoint','cool_comfort_setpoint','cool_sleep_setpoint','cool_away_setpoint','heat_comfort_setpoint','heat_sleep_setpoint','heat_away_setpoint','hysteresis','min_on_seconds','min_off_seconds','min_adjust_seconds','standby_offset_c','external_sensor_weight_percent','max_sensor_difference'].forEach(name=>{ if(!f[name])return; const value=name==='external_sensor_weight_percent'?(source.external_sensor_weight*100):source[name]; if(value!==undefined&&value!==null)f[name].value=String(value); }); f.smart_fan.checked=source.smart_fan!==false; if(f.mode_policy) f.mode_policy.value=source.inherit_house_mode===false?(source.mode||'cool'):'house'; toast('Thermostat settings copied'); });
|
||||||
|
|
||||||
|
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; 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', event => {
|
$('#tokenForm').addEventListener('submit', event => {
|
||||||
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
|
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
|
||||||
localStorage.setItem('gree_controller_token', app.token); if (app.ws) app.ws.close(); loadBootstrap();
|
localStorage.setItem('gree_controller_token', app.token); if (app.ws) app.ws.close(); loadBootstrap();
|
||||||
@@ -1453,12 +1484,12 @@ $('#zoneForm').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));
|
||||||
const id=raw.id; const body={
|
const id=raw.id; const body={
|
||||||
name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,
|
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:Number(raw.setpoint),
|
mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint),
|
||||||
cool_comfort_setpoint:Number(raw.cool_comfort_setpoint),cool_sleep_setpoint:Number(raw.cool_sleep_setpoint),cool_away_setpoint:Number(raw.cool_away_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:Number(raw.heat_comfort_setpoint),heat_sleep_setpoint:Number(raw.heat_sleep_setpoint),heat_away_setpoint:Number(raw.heat_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:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),
|
hysteresis:parseDecimal(raw.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:Number(raw.standby_offset_c),smart_fan:form.smart_fan.checked,
|
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:Number(raw.max_sensor_difference)
|
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)
|
||||||
};
|
};
|
||||||
try { 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')); }
|
try { 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')); }
|
||||||
catch(error){toast(error.message,true);}
|
catch(error){toast(error.message,true);}
|
||||||
@@ -1467,15 +1498,15 @@ $('#zoneForm').addEventListener('submit', async event => {
|
|||||||
$('#scheduleForm').addEventListener('submit', async event => {
|
$('#scheduleForm').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));
|
||||||
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
|
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:Number(raw.setpoint||23)};
|
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)};
|
||||||
try { 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')); }
|
try { 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')); }
|
||||||
catch(error){toast(error.message,true);}
|
catch(error){toast(error.message,true);}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#automationForm').addEventListener('submit', async event => {
|
$('#automationForm').addEventListener('submit', async event => {
|
||||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
|
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
|
||||||
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(raw.action_target_temperature!=='') action.target_temperature=Number(raw.action_target_temperature);
|
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(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:Number(raw.threshold),at_time:raw.at_time||null,action_device_id:raw.action_device_id,action,cooldown_seconds:Number(raw.cooldown_seconds)};
|
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:raw.action_device_id,action,cooldown_seconds:Number(raw.cooldown_seconds)};
|
||||||
try { 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')); }
|
try { 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')); }
|
||||||
catch(error){toast(error.message,true);}
|
catch(error){toast(error.message,true);}
|
||||||
});
|
});
|
||||||
@@ -1498,6 +1529,11 @@ function currentSettingsBody() {
|
|||||||
history_compaction_enabled: settings.history_compaction_enabled !== false,
|
history_compaction_enabled: settings.history_compaction_enabled !== false,
|
||||||
event_log_retention_days: Number(settings.event_log_retention_days || 30),
|
event_log_retention_days: Number(settings.event_log_retention_days || 30),
|
||||||
suppress_device_beep: !!settings.suppress_device_beep,
|
suppress_device_beep: !!settings.suppress_device_beep,
|
||||||
|
notifications: {
|
||||||
|
enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover',
|
||||||
|
pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '',
|
||||||
|
cooldown_seconds: Number(settings.notifications?.cooldown_seconds || 300), communication_failure_threshold: Number(settings.notifications?.communication_failure_threshold || 3), target_timeout_minutes: Number(settings.notifications?.target_timeout_minutes || 60),
|
||||||
|
},
|
||||||
night_mode: {
|
night_mode: {
|
||||||
enabled: !!settings.night_mode?.enabled,
|
enabled: !!settings.night_mode?.enabled,
|
||||||
start_time: settings.night_mode?.start_time || '22:00',
|
start_time: settings.night_mode?.start_time || '22:00',
|
||||||
@@ -1553,6 +1589,14 @@ function settingsBodyFromForm(form) {
|
|||||||
history_threshold_days: Number(raw.influx_threshold_days),
|
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 || '',
|
||||||
|
slack_webhook_url: raw.slack_webhook_url || '', discord_webhook_url: raw.discord_webhook_url || '',
|
||||||
|
cooldown_seconds: Number(raw.notification_cooldown_seconds || 300),
|
||||||
|
communication_failure_threshold: Number(raw.notification_failure_threshold || 3),
|
||||||
|
target_timeout_minutes: Number(raw.notification_target_timeout || 60),
|
||||||
|
};
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,7 +1782,7 @@ async function startApplication() {
|
|||||||
updateSchedulePresetField();
|
updateSchedulePresetField();
|
||||||
await loadBootstrap();
|
await loadBootstrap();
|
||||||
applyRouteFromLocation();
|
applyRouteFromLocation();
|
||||||
if (location.pathname === '/') updateBrowserUrl('/dashboard', true);
|
if (location.pathname === '/' || location.pathname === `${APP_BASE}/`) updateBrowserUrl('/dashboard', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
startApplication().catch(error => console.error('Application startup failed:', error));
|
startApplication().catch(error => console.error('Application startup failed:', error));
|
||||||
|
|||||||
+33
-24
@@ -5,19 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#151515" id="themeColorMeta">
|
<meta name="theme-color" content="#151515" id="themeColorMeta">
|
||||||
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
|
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="__GREE_BASE_PATH__/manifest.webmanifest">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="__GREE_BASE_PATH__/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="__GREE_BASE_PATH__/styles.css">
|
||||||
<script>
|
<script src="__GREE_BASE_PATH__/theme-init.js"></script>
|
||||||
(() => {
|
|
||||||
const getCookie = name => document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=')[1];
|
|
||||||
const requested = decodeURIComponent(getCookie('gree_controller_theme') || 'system');
|
|
||||||
const resolved = requested === 'light' || requested === 'dark'
|
|
||||||
? requested
|
|
||||||
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
||||||
document.documentElement.dataset.theme = resolved;
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<title>GREE Controller</title>
|
<title>GREE Controller</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -202,6 +193,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<section class="panel settings-block">
|
||||||
|
<div class="settings-block-head"><div><h3 data-i18n="notifications.title">Notifications</h3><p data-i18n="notifications.hint">Send important events or only problems/anomalies.</p></div></div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="check wide"><input type="checkbox" name="notifications_enabled"> <span data-i18n="notifications.enable">Enable notifications</span></label>
|
||||||
|
<label><span data-i18n="notifications.mode">Mode</span><select name="notifications_mode"><option value="problems" data-i18n="notifications.problems">Errors and anomalies only</option><option value="important" data-i18n="notifications.important">Important events + problems</option></select></label>
|
||||||
|
<label><span data-i18n="notifications.provider">Provider</span><select name="notifications_provider"><option value="pushover">Pushover</option><option value="slack">Slack</option><option value="discord">Discord</option></select></label>
|
||||||
|
<div class="wide notification-provider" data-notification-provider="pushover"><label><span data-i18n="notifications.pushoverToken">Pushover application token</span><input type="password" name="pushover_app_token" autocomplete="new-password" placeholder="Leave empty to keep saved secret"></label><label><span data-i18n="notifications.pushoverUser">Pushover user/group key</span><input type="password" name="pushover_user_key" autocomplete="new-password" placeholder="Leave empty to keep saved secret"></label></div>
|
||||||
|
<label class="wide notification-provider" data-notification-provider="slack"><span data-i18n="notifications.slackWebhook">Slack incoming webhook</span><input type="password" name="slack_webhook_url" autocomplete="new-password" placeholder="https://hooks.slack.com/services/…"></label>
|
||||||
|
<label class="wide notification-provider" data-notification-provider="discord"><span data-i18n="notifications.discordWebhook">Discord webhook</span><input type="password" name="discord_webhook_url" autocomplete="new-password" placeholder="https://discord.com/api/webhooks/…"></label>
|
||||||
|
<label><span data-i18n="notifications.cooldown">Cooldown (s)</span><input type="number" name="notification_cooldown_seconds" min="30" max="86400" value="300"></label>
|
||||||
|
<label><span data-i18n="notifications.failureThreshold">Communication failures before alert</span><input type="number" name="notification_failure_threshold" min="2" max="100" value="3"></label>
|
||||||
|
<label><span data-i18n="notifications.targetTimeout">Target not reached timeout (min)</span><input type="number" name="notification_target_timeout" min="5" max="1440" value="60"></label>
|
||||||
|
<div class="form-actions wide"><button type="button" class="secondary" id="testNotifications" data-i18n="notifications.test">Send test notification</button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel settings-block">
|
<section class="panel settings-block">
|
||||||
<div class="settings-block-head"><div><h3 data-i18n="settings.metricsAndLogs">Metrics and logs</h3><p data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p></div></div>
|
<div class="settings-block-head"><div><h3 data-i18n="settings.metricsAndLogs">Metrics and logs</h3><p data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p></div></div>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
@@ -260,7 +267,7 @@
|
|||||||
|
|
||||||
<section class="view" data-view="logs">
|
<section class="view" data-view="logs">
|
||||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
||||||
<div class="panel logs-toolbar"><div><strong data-i18n="logs.retention">Log retention</strong><small data-i18n="logs.retentionHint">Old event rows are removed automatically during maintenance.</small></div><label><span data-i18n="logs.keepFor">Keep for</span><select id="logRetentionDays"><option value="7">7 days</option><option value="14">14 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></label><button class="secondary" id="saveLogRetention" data-i18n="actions.save">Save</button></div>
|
<div class="panel logs-toolbar"><div><strong data-i18n="logs.retention">Log retention</strong><small data-i18n="logs.retentionHint">Old event rows are removed automatically during maintenance.</small></div><label><span data-i18n="logs.level">Level</span><select id="logLevelFilter"><option value="all" data-i18n="logs.all">All</option><option value="error" data-i18n="logs.errors">Errors</option><option value="warn" data-i18n="logs.warnings">Warnings</option><option value="info">Info</option></select></label><label><span data-i18n="logs.category">Category</span><select id="logCategoryFilter"><option value="all" data-i18n="logs.all">All</option><option value="device" data-i18n="nav.devices">Devices</option><option value="zone" data-i18n="nav.zones">Thermostats</option><option value="automation" data-i18n="nav.automations">Automations</option><option value="settings" data-i18n="nav.settings">Settings</option><option value="integration" data-i18n="logs.integrations">Integrations</option><option value="system" data-i18n="logs.systemOther">System/other</option></select></label><label><span data-i18n="logs.keepFor">Keep for</span><select id="logRetentionDays"><option value="7">7 days</option><option value="14">14 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></label><button class="secondary" id="saveLogRetention" data-i18n="actions.save">Save</button></div>
|
||||||
<div class="panel log-list" id="logList"></div>
|
<div class="panel log-list" id="logList"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -336,18 +343,19 @@
|
|||||||
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
|
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
|
||||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
|
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
|
||||||
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
||||||
<div class="two"><label><span data-i18n="zones.modePolicy">Mode policy</span><select name="mode_policy"><option value="house" data-i18n="zones.followHouse">Follow house mode</option><option value="cool" data-i18n="mode.cool">Cooling only</option><option value="heat" data-i18n="mode.heat">Heating only</option></select></label><label><span data-i18n="common.targetC">Manual target °C</span><input type="number" step="0.5" min="8" max="30" name="setpoint" value="23"></label></div>
|
<div class="copy-settings-row"><select id="copyZoneSource"><option value="" data-i18n="zones.copyFrom">Copy thermostat settings from…</option></select><button type="button" class="secondary" id="copyZoneSettings" data-i18n="zones.copySettings">Copy settings</button></div>
|
||||||
|
<div class="two"><label><span data-i18n="zones.modePolicy">Mode policy</span><select name="mode_policy"><option value="house" data-i18n="zones.followHouse">Follow house mode</option><option value="cool" data-i18n="mode.cool">Cooling only</option><option value="heat" data-i18n="mode.heat">Heating only</option></select></label><label><span data-i18n="common.targetC">Manual target °C</span><input type="text" inputmode="decimal" step="0.5" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||||
<details class="profile-editor"><summary data-i18n="zones.profileTemperatures">Comfort / sleep / away temperatures</summary>
|
<details class="profile-editor"><summary data-i18n="zones.profileTemperatures">Comfort / sleep / away temperatures</summary>
|
||||||
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="number" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="number" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="number" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
||||||
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="number" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="number" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="number" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
||||||
</details>
|
</details>
|
||||||
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="number" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||||
<div id="externalSensorFields">
|
<div id="externalSensorFields">
|
||||||
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||||
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="number" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
|
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
|
||||||
<p class="field-note" data-i18n="zones.sensorHelp">The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
|
<p class="field-note" data-i18n="zones.sensorHelp">The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="number" step="0.5" min="0.5" max="8" name="standby_offset_c" value="2"></label></div>
|
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="text" inputmode="decimal" step="0.5" min="0.5" max="8" name="standby_offset_c" value="2"></label></div>
|
||||||
<label class="check"><input type="checkbox" name="smart_fan" checked> <span data-i18n="zones.smartFan">Smart fan assist</span></label>
|
<label class="check"><input type="checkbox" name="smart_fan" checked> <span data-i18n="zones.smartFan">Smart fan assist</span></label>
|
||||||
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds" value="180">
|
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds" value="180">
|
||||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||||
@@ -364,7 +372,7 @@
|
|||||||
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
|
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
|
||||||
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
|
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
|
||||||
<label><span data-i18n="schedules.profile">Profile</span><select name="preset"><option value="comfort" data-i18n="preset.comfort">Comfort</option><option value="sleep" data-i18n="preset.sleep">Sleep</option><option value="away" data-i18n="preset.away">Away</option><option value="custom" data-i18n="preset.custom">Custom temperature</option></select></label>
|
<label><span data-i18n="schedules.profile">Profile</span><select name="preset"><option value="comfort" data-i18n="preset.comfort">Comfort</option><option value="sleep" data-i18n="preset.sleep">Sleep</option><option value="away" data-i18n="preset.away">Away</option><option value="custom" data-i18n="preset.custom">Custom temperature</option></select></label>
|
||||||
<label id="scheduleSetpointField"><span data-i18n="common.temperatureC">Custom temperature °C</span><input type="number" name="setpoint" step="0.5" min="8" max="30" value="23"></label>
|
<label id="scheduleSetpointField"><span data-i18n="common.temperatureC">Custom temperature °C</span><input type="text" inputmode="decimal" name="setpoint" step="0.5" min="8" max="30" value="23"></label>
|
||||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -374,14 +382,15 @@
|
|||||||
<form method="dialog" id="automationForm" class="dialog-form">
|
<form method="dialog" id="automationForm" class="dialog-form">
|
||||||
<input type="hidden" name="id">
|
<input type="hidden" name="id">
|
||||||
<div class="dialog-head"><h2 data-i18n="common.automation">Automation</h2><button type="button" data-close>×</button></div>
|
<div class="dialog-head"><h2 data-i18n="common.automation">Automation</h2><button type="button" data-close>×</button></div>
|
||||||
|
<div class="copy-settings-row"><select id="automationPreset"><option value="" data-i18n="automations.preset">Preset…</option><option value="hot" data-i18n="automations.presetHot">High temperature cooling</option><option value="cold" data-i18n="automations.presetCold">Low temperature heating</option><option value="morning" data-i18n="automations.presetMorning">Morning comfort</option><option value="nightoff" data-i18n="automations.presetNightOff">Night power off</option></select><button type="button" class="secondary" id="applyAutomationPreset" data-i18n="actions.apply">Apply</button></div>
|
||||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="automations.namePlaceholder" placeholder="Emergency cooling"></label>
|
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="automations.namePlaceholder" placeholder="Emergency cooling"></label>
|
||||||
<label><span data-i18n="automations.trigger">Trigger</span><select name="trigger_kind"><option value="temperature_above" data-i18n="automations.tempAbove">Temperature above</option><option value="temperature_below" data-i18n="automations.tempBelow">Temperature below</option><option value="time" data-i18n="automations.time">Time</option></select></label>
|
<label><span data-i18n="automations.trigger">Trigger</span><select name="trigger_kind"><option value="temperature_above" data-i18n="automations.tempAbove">Temperature above</option><option value="temperature_below" data-i18n="automations.tempBelow">Temperature below</option><option value="time" data-i18n="automations.time">Time</option></select></label>
|
||||||
<label><span data-i18n="automations.measurementDevice">Measurement device</span><select name="trigger_device_id"></select></label>
|
<label><span data-i18n="automations.measurementDevice">Measurement device</span><select name="trigger_device_id"></select></label>
|
||||||
<div class="two"><label><span data-i18n="automations.thresholdC">Threshold °C</span><input type="number" step="0.1" name="threshold" value="27"></label><label><span data-i18n="automations.time">Time</span><input type="time" name="at_time" value="08:00"></label></div>
|
<div class="two"><label><span data-i18n="automations.thresholdC">Threshold °C</span><input type="text" inputmode="decimal" step="0.1" name="threshold" value="27"></label><label><span data-i18n="automations.time">Time</span><input type="time" name="at_time" value="08:00"></label></div>
|
||||||
<hr><h3 data-i18n="automations.action">Action</h3>
|
<hr><h3 data-i18n="automations.action">Action</h3>
|
||||||
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
|
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
|
||||||
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
|
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
|
||||||
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
|
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="text" inputmode="decimal" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
|
||||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -409,6 +418,6 @@
|
|||||||
<div id="debugOverlayLines" class="debug-overlay-lines"></div>
|
<div id="debugOverlayLines" class="debug-overlay-lines"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="toastStack" class="toast-stack" role="status" aria-live="polite" aria-atomic="false"></div>
|
<div id="toastStack" class="toast-stack" role="status" aria-live="polite" aria-atomic="false"></div>
|
||||||
<script src="/app.js" defer></script>
|
<script src="__GREE_BASE_PATH__/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"name":"GREE Controller","short_name":"GREE","start_url":"/dashboard","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"}]}
|
||||||
|
|||||||
@@ -621,3 +621,5 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
|
|||||||
.device-capability-buttons button:hover { color:var(--text); background:var(--surface-muted); }
|
.device-capability-buttons button:hover { color:var(--text); background:var(--surface-muted); }
|
||||||
.device-capability-buttons button.active { color:var(--accent); background:color-mix(in srgb, var(--accent) 12%, var(--surface)); border-color:color-mix(in srgb, var(--accent) 45%, var(--line)); }
|
.device-capability-buttons button.active { color:var(--accent); background:color-mix(in srgb, var(--accent) 12%, var(--surface)); border-color:color-mix(in srgb, var(--accent) 45%, var(--line)); }
|
||||||
.standalone-settings-form { max-width:1100px; }
|
.standalone-settings-form { max-width:1100px; }
|
||||||
|
|
||||||
|
.copy-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.6rem;align-items:end}.notification-provider{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.log-row{display:grid;grid-template-columns:5.5rem 7rem minmax(9rem,auto) 1fr;gap:.75rem;align-items:center;border-left:4px solid transparent}.log-row.error{border-left-color:var(--danger,#d9544d)}.log-row.warn{border-left-color:#c98a21}.log-row.info{border-left-color:var(--accent,#4d9c7d)}.log-category{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;opacity:.7}.category-device .log-category:before{content:'▣ '}.category-zone .log-category:before{content:'◎ '}.category-automation .log-category:before{content:'⚙ '}.category-integration .log-category:before{content:'↔ '}.category-settings .log-category:before{content:'☷ '}.category-system .log-category:before{content:'• '}@media(max-width:720px){.copy-settings-row,.notification-provider{grid-template-columns:1fr}.log-row{grid-template-columns:4.5rem 1fr}.log-row .kind,.log-row .message{grid-column:2}.logs-toolbar{align-items:stretch}}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
const CACHE = 'gree-controller-v055';
|
const CACHE = 'gree-controller-v060';
|
||||||
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
|
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
||||||
|
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
|
||||||
|
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];
|
||||||
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
||||||
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('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 => {
|
self.addEventListener('fetch', event => {
|
||||||
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.method !== 'GET' || url.pathname.startsWith(path('/api/'))) return;
|
||||||
event.respondWith(fetch(event.request).then(response => {
|
event.respondWith(fetch(event.request).then(response => {
|
||||||
const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response;
|
const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response;
|
||||||
}).catch(() => caches.match(event.request).then(response => response || caches.match('/'))));
|
}).catch(() => caches.match(event.request).then(response => response || caches.match(path('/')))));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use strict';
|
||||||
|
(() => {
|
||||||
|
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_theme='));
|
||||||
|
const requested = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'system';
|
||||||
|
const resolved = requested === 'light' || requested === 'dark'
|
||||||
|
? requested
|
||||||
|
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user