diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index cdc3fe4..e18e109 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -65,3 +65,31 @@ cargo build --release ``` 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. diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 686fa92..09346bc 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,16 +1,18 @@ -e027a3bf37a7917e249ebffb980d43b5e18c70e5931113271975cbeda81a88bd ./.env.example +623bd769512ef75f51e9bf7fb685d4d59ec84d7ccc75c826fe72c5538be737c5 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -52c2c492893ce42e68dac763533a5070170c21b18d67b302a4e1380a4d4cc4ab ./BUILD_REPORT.md +b26651d11ad5156406250dd7eb3061c5d47fa0c999a6899ea13a97aa6f295178 ./BUILD_REPORT.md 4202057467ffdc3f53ef250c3e7852a929be788bb0af03fbd2366a57ce42b79e ./Cargo.lock 9edc867d716d9d8ce2c54cdf17e16628f9c2b338ee36f88e0bb0fcb4f7884b57 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 44675b0d5d9e6e89dd0bae8eff3ab76a190a3b1bdd6507e7402957bbd92cac36 ./README.md -a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs +f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs 674f8cf1ab9f1a49188e20b8e3977f1e2b7e9b8ef975315266730c44a55bbb48 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md 47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md +7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md +28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md 925ffa324b1b2cfad7ca40354e8255548f70627f95c4bb345b957d3f7e53694c ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.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 8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py +c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png +c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png f79cc14c65131fa86a5bf41d55f49b60a3aa71a6d15de98027de830e3b11b2a1 ./home-assistant/custom_components/gree_controller/manifest.json d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.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 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -eb5370fbee1a8edc11a536ff0e68dba2152767734feb2976fef3cb7cc8c885f4 ./lang/en.json -9e4f43dd809f2d92aca8ce6813e843dbf7e2f38adcfd9cdae584a94e22197fa3 ./lang/pl.json +ab2f7193ad789a286f0a0ba23bd53b9816eb469dcfdf0860033543760b942bf3 ./lang/en.json +510bd3303d99cea8fbf10261dfbf0351da136d8844b7225f746be8b5b187e19e ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md -3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh +5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh 14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh -cae0900fbc67e7cea311c4bd334d774f339092e92721c9c2b1570ca91920a53a ./scripts/install.sh +bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -ca63498714f3e154fd98c0c2157ab355bf36fc2208d7b3f82c201e153749fb04 ./src/api.rs -31ef3ddbc096e580e9212486683e4db86998322ee87190c61b0cab5ae6bc8fc9 ./src/config.rs +87b985ba7095a3b165928fe6d051acf34c34d2b48ce882b2b2c89c486f3f2ad3 ./src/api.rs +0f50b30a0088b6ae8289ecf42be1938cbcd95f672d370cff7e5446e7e1f02b83 ./src/config.rs a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs -9dfea316b39da6ded85cb9c7ec6f6a61e0cd9a51f40469145782d5766c9cf7a4 ./src/engine.rs +614bc52f203f2de657266a74fa93933a8b30a4a631b208282c88813687a0db37 ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs -1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs -b3f95c0fc9dfb2ef6d3bb75f5b61c434ed004cfa7fee2c8a93ead194afde5a27 ./src/models.rs +31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs +6be14e8e3286c9ce5bd921ccaa39431bb2d3cd882d2945fa8b17b2f00bf5a8fb ./src/models.rs +30e9dd984027405a726c3f281673c28ae4d48c0fb8af823b3c0e4c5706505263 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs 37bd46ac7c50d1674463eddcf2ee9c85cdbf8fe0829cef13a3f5fe857d3d0f55 ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs -e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs +f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -17342e3aa40a55285f5d056502b232e6aeed41a64ff3e1fdf94c0d8278385ffe ./web/app.js +ef02fe53df75af12cb957e15b945543eeba937a13154a1fc6c5234ee6f25b435 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -1474d31bf8266f6b6dc2dc47225644394259dc6e0afc8e09f0eaddfa93db1d82 ./web/index.html -c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest -b8a3cecbde31f9aa4ed3afb5755c62cf98ad90a12faf0a616835170ee889e252 ./web/styles.css -f8bda0083c138828a037469d3db189559190d4b893bcff764a6f9bfd9030b265 ./web/sw.js +002c172392a00006a5128fc4cb881b0361a2d3e76298dbd80c0a013f4e1f9dde ./web/index.html +fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest +648630cb390be4ef7654053caa4010760e5f963ea9d67ccec4e6b07a5e2545f7 ./web/styles.css +87d25648f24b853c94b39e31e93fe737cae524c74402f287a586d0b5718ca39e ./web/sw.js +d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/build.rs b/build.rs index 6ef62f4..0a953c7 100644 --- a/build.rs +++ b/build.rs @@ -61,7 +61,7 @@ fn main() { "name": name, "native_name": native_name, "locale": locale, - "path": format!("/lang/{code}.json") + "path": format!("lang/{code}.json") })); assets.push((code.to_owned(), path)); } diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md new file mode 100644 index 0000000..f5e95c0 --- /dev/null +++ b/docs/REVERSE_PROXY.md @@ -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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..69acc55 --- /dev/null +++ b/docs/SECURITY.md @@ -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. diff --git a/home-assistant/custom_components/gree_controller/icon.png b/home-assistant/custom_components/gree_controller/icon.png new file mode 100644 index 0000000..6105675 Binary files /dev/null and b/home-assistant/custom_components/gree_controller/icon.png differ diff --git a/home-assistant/custom_components/gree_controller/logo.png b/home-assistant/custom_components/gree_controller/logo.png new file mode 100644 index 0000000..6105675 Binary files /dev/null and b/home-assistant/custom_components/gree_controller/logo.png differ diff --git a/lang/en.json b/lang/en.json index a094df6..1c45d49 100644 --- a/lang/en.json +++ b/lang/en.json @@ -390,7 +390,7 @@ "settings.debugOverlay": "Show debug window on every page", "settings.debugGreeFrames": "Include GREE protocol frames", "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.import": "Import settings", "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.", "homeAssistant.eyebrow": "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" } } diff --git a/lang/pl.json b/lang/pl.json index 284fe5c..1c12757 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -390,7 +390,7 @@ "settings.debugOverlay": "Pokazuj okno debug na każdej podstronie", "settings.debugGreeFrames": "Dołącz ramki protokołu GREE", "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.import": "Importuj ustawienia", "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.", "homeAssistant.eyebrow": "Integracja", "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" } } diff --git a/scripts/common.sh b/scripts/common.sh index 0d2d2cb..042d4ec 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -93,10 +93,13 @@ read_env_value() { } health_url() { - local bind port + local bind port base bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')" 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() { @@ -109,11 +112,14 @@ database_path() { } panel_url() { - local bind port ip + local bind port ip base bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')" port="${bind##*:}" 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() { diff --git a/scripts/install.sh b/scripts/install.sh index 55c4af3..cf895e3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" if [[ ! -f "$ENV_FILE" ]]; then - token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" cat > "$ENV_FILE" < Router { .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/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)); 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_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); - Router::new() + let app = Router::new() .route("/api/health", get(health)) .route("/ws", get(websocket)) .route("/", get(index)) .route("/index.html", get(index)) .route("/app.js", get(app_js)) + .route("/theme-init.js", get(theme_init_js)) .route("/styles.css", get(styles_css)) .route("/manifest.webmanifest", get(manifest)) .route("/sw.js", get(service_worker)) @@ -91,9 +95,25 @@ pub fn router(state: AppState) -> Router { .merge(protected) .merge(home_assistant_api) .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(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn(security_headers)) .layer(middleware::from_fn_with_state(state.clone(), debug_api_requests)) .with_state(state) } @@ -466,7 +486,7 @@ impl ZoneInput { 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, 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(), } } @@ -503,6 +523,8 @@ async fn update_zone(State(state): State, Path(id): Path, Json zone.effective_setpoint = existing.effective_setpoint; zone.device_setpoint = existing.device_setpoint; 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; let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); @@ -1129,6 +1151,15 @@ async fn update_settings(State(state): State, Json(mut input): Json, Json(input): Json, Json(mut input): Json) -> Result, 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 { json!({ "controller_id": settings.controller_id, @@ -1333,6 +1387,21 @@ fn public_settings(settings: &RuntimeSettings) -> Value { "suppress_device_beep": settings.suppress_device_beep, "debug": settings.debug, "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": { "enabled": settings.influxdb.enabled, "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, 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 { + 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 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 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") } diff --git a/src/config.rs b/src/config.rs index c8d955f..ab208fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use std::{env, net::SocketAddr, path::PathBuf}; use anyhow::{Context, Result}; use clap::Parser; -use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings}; +use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings}; #[derive(Debug, Clone, Parser)] #[command(author, version, about)] @@ -12,6 +12,8 @@ pub struct Config { pub database: PathBuf, #[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")] 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)] pub simulate: bool, #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)] @@ -33,7 +35,8 @@ pub struct Config { impl Config { pub fn load() -> Result { 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() { std::fs::create_dir_all(parent) .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), gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false), }, + notifications: NotificationSettings::default(), night_mode: NightModeSettings { 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()), @@ -128,6 +132,15 @@ impl Config { } } +fn normalize_base_path(value: &str) -> Result { + 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 { env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) } diff --git a/src/engine.rs b/src/engine.rs index 3a2b044..6347a67 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -198,6 +198,7 @@ async fn poll_device(state: &AppState, device: &mut Device) { simulate_tick(device); return; } + let previous_failures = device.communication_failures; if device.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(device).await { Ok(bound) => { @@ -207,6 +208,7 @@ async fn poll_device(state: &AppState, device: &mut Device) { } Err(err) => { record_poll_failure(device, &err.to_string()); + log_poll_health_transition(state, device, previous_failures).await; return; } } @@ -225,6 +227,18 @@ async fn poll_device(state: &AppState, device: &mut Device) { 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) { @@ -472,6 +486,24 @@ async fn control_zones(state: &AppState) -> Result<()> { 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 // 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, 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, - 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(), } } diff --git a/src/main.rs b/src/main.rs index 4c96f24..cea39f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod error; mod home_assistant; mod influxdb; mod models; +mod notifications; mod protocol; mod queries; mod state; diff --git a/src/models.rs b/src/models.rs index 80d670e..4f6e450 100644 --- a/src/models.rs +++ b/src/models.rs @@ -326,6 +326,10 @@ pub struct Zone { #[serde(default)] pub demand: bool, #[serde(default)] + pub demand_since: Option>, + #[serde(default)] + pub target_alerted_at: Option>, + #[serde(default)] pub last_action_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, @@ -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)] pub struct DebugSettings { #[serde(default)] @@ -661,6 +711,8 @@ pub struct RuntimeSettings { pub debug: DebugSettings, #[serde(default)] pub night_mode: NightModeSettings, + #[serde(default)] + pub notifications: NotificationSettings, pub home_assistant: HomeAssistantSettings, } diff --git a/src/notifications.rs b/src/notifications.rs new file mode 100644 index 0000000..9ffa0ee --- /dev/null +++ b/src/notifications.rs @@ -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>> = 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()), + } +} diff --git a/src/state.rs b/src/state.rs index 89798c4..fb3b737 100644 --- a/src/state.rs +++ b/src/state.rs @@ -36,5 +36,12 @@ impl AppState { "message": message, "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; }); + } } } diff --git a/web/app.js b/web/app.js index cfd4900..a5b1d9b 100644 --- a/web/app.js +++ b/web/app.js @@ -1,11 +1,18 @@ '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 row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`)); return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : ''; }; 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'; @@ -71,7 +78,7 @@ function applyTranslations() { $$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); }); $$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); }); $('#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); renderAll(); if (app.currentView === 'logs') loadLogs(); @@ -90,21 +97,21 @@ function renderLanguageOptions() { if (!select) return; select.innerHTML = app.languages.map(item => { const label = item.native_name || item.name || item.code.toUpperCase(); - return ``; + const flag = ({pl:'🇵🇱',en:'🇬🇧'})[item.code] || '🌐'; return ``; }).join(''); select.value = app.language; } async function loadLanguages() { 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}`); const manifest = await response.json(); const languages = Array.isArray(manifest.languages) ? manifest.languages : []; if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing'); const loaded = await Promise.all(languages.map(async item => { - const packResponse = await fetch(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}`); const pack = await packResponse.json(); return {item, pack}; @@ -143,7 +150,7 @@ async function api(path, options = {}) { headers.set('Content-Type', 'application/json'); 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) { showTokenDialog(); 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; } const presetZone = $('#schedulePresetZone'); if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; } + const copyZone=$('#copyZoneSource'); if(copyZone){ const current=copyZone.value; copyZone.innerHTML=''+app.zones.map(z=>``).join(''); if([...copyZone.options].some(o=>o.value===current))copyZone.value=current; } } function renderAccessTokens() { @@ -691,6 +699,15 @@ function renderSettings() { form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled; form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames; + const n=app.settings.notifications||{}; + form.notifications_enabled.checked=!!n.enabled; form.notifications_mode.value=n.mode||'problems'; form.notifications_provider.value=n.provider||'pushover'; + form.pushover_app_token.value=''; form.pushover_user_key.value=''; form.slack_webhook_url.value=''; form.discord_webhook_url.value=''; + form.pushover_app_token.placeholder=n.pushover_configured?'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(); renderLogRetention(); $('#systemInfo').innerHTML = `

${esc(tr('settings.systemState'))}

${esc(tr('settings.version'))}: ${esc(app.system.version || '—')}
${esc(tr('settings.uptime'))}: ${esc(formatDuration(app.system.uptime_seconds || 0))}
${esc(tr('settings.apiAuth'))}: ${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}
`; @@ -728,6 +745,9 @@ function updateInfluxFields() { $$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; }); } +function updateNotificationFields(){ const provider=$('#settingsForm [name=notifications_provider]')?.value||'pushover'; $$('[data-notification-provider]').forEach(n=>n.hidden=n.dataset.notificationProvider!==provider); } + +function logCategory(kind=''){ const prefix=String(kind).split('.')[0]; return ['device','zone','automation','settings'].includes(prefix)?prefix:(['home_assistant','influx','notification'].includes(prefix)?'integration':'system'); } function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) { app.debugLines.push({source, kind, message, timestamp, data}); if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160); @@ -781,9 +801,10 @@ function currentHistoryPath() { } function updateBrowserUrl(path, replace=false) { + const target = `${APP_BASE}${path}` || path; const current = `${location.pathname}${location.search}`; - if (current === path) return; - history[replace ? 'replaceState' : 'pushState']({}, '', path); + if (current === target) return; + history[replace ? 'replaceState' : 'pushState']({}, '', target); } function showView(name, {push=true, scroll=true}={}) { @@ -804,7 +825,8 @@ function showHistoryTab(tab, {push=true, load=true}={}) { } 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 params = new URLSearchParams(location.search); if (first === 'history') { @@ -1282,7 +1304,7 @@ async function handleHistoryAction(button) { } if(action==='copy-chart-link'){ if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); - const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${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(); } toast(tr('history.linkCopied')); return; } @@ -1292,9 +1314,10 @@ async function loadLogs() { renderLogRetention(); try { 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 - ? logs.map(item => `
${esc(item.kind)}${esc(item.message)}
`).join('') + ? logs.map(item => `
${esc(logCategory(item.kind))}${esc(item.kind)}${esc(item.message)}
`).join('') : `
${esc(tr('logs.emptyTitle'))}${esc(tr('logs.emptyText'))}
`; } catch (error) { toast(error.message, true); } } @@ -1304,7 +1327,7 @@ function connectWebSocket() { clearTimeout(app.wsTimer); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; 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.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; ws.onerror = () => updateConnectionIndicator('connectionError'); @@ -1404,9 +1427,17 @@ $('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHi $('#logsRefresh').addEventListener('click', loadLogs); $('#languageSelect').addEventListener('change', event => setLanguage(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); $('#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 => { 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(); @@ -1453,12 +1484,12 @@ $('#zoneForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id; const body={ name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked, - mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:Number(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), - heat_comfort_setpoint:Number(raw.heat_comfort_setpoint),heat_sleep_setpoint:Number(raw.heat_sleep_setpoint),heat_away_setpoint:Number(raw.heat_away_setpoint), - hysteresis:Number(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, - 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) + mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint), + cool_comfort_setpoint:parseDecimal(raw.cool_comfort_setpoint),cool_sleep_setpoint:parseDecimal(raw.cool_sleep_setpoint),cool_away_setpoint:parseDecimal(raw.cool_away_setpoint), + heat_comfort_setpoint:parseDecimal(raw.heat_comfort_setpoint),heat_sleep_setpoint:parseDecimal(raw.heat_sleep_setpoint),heat_away_setpoint:parseDecimal(raw.heat_away_setpoint), + hysteresis:parseDecimal(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds), + min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked, + sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference) }; 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);} @@ -1467,15 +1498,15 @@ $('#zoneForm').addEventListener('submit', async event => { $('#scheduleForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value)); - const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint: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')); } catch(error){toast(error.message,true);} }); $('#automationForm').addEventListener('submit', async event => { 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 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 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: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')); } catch(error){toast(error.message,true);} }); @@ -1498,6 +1529,11 @@ function currentSettingsBody() { history_compaction_enabled: settings.history_compaction_enabled !== false, event_log_retention_days: Number(settings.event_log_retention_days || 30), 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: { enabled: !!settings.night_mode?.enabled, start_time: settings.night_mode?.start_time || '22:00', @@ -1553,6 +1589,14 @@ function settingsBodyFromForm(form) { 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.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; } @@ -1738,7 +1782,7 @@ async function startApplication() { updateSchedulePresetField(); await loadBootstrap(); 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)); diff --git a/web/index.html b/web/index.html index 5438598..c55e9a8 100644 --- a/web/index.html +++ b/web/index.html @@ -5,19 +5,10 @@ - - - - + + + + GREE Controller @@ -202,6 +193,22 @@ +
+

Notifications

Send important events or only problems/anomalies.

+
+ + + +
+ + + + + +
+
+
+

Metrics and logs

SQLite keeps recent data locally and compacts older samples to the resolution used by charts.

@@ -260,7 +267,7 @@
Diagnostics

Events

-
Log retentionOld event rows are removed automatically during maintenance.
+
Log retentionOld event rows are removed automatically during maintenance.
@@ -336,18 +343,19 @@

Zone

-
+
+
Comfort / sleep / away temperatures -
Cooling
-
Heating
+
Cooling
+
Heating
-
+
-
+

The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.

-
+
@@ -364,7 +372,7 @@
Weekdays
- +
@@ -374,14 +382,15 @@

Automation

+
-
+

Action

-
+
@@ -409,6 +418,6 @@
- + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest index 23d3978..cb66a71 100644 --- a/web/manifest.webmanifest +++ b/web/manifest.webmanifest @@ -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"}]} diff --git a/web/styles.css b/web/styles.css index 39fcd6b..900e8df 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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.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; } + +.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}} diff --git a/web/sw.js b/web/sw.js index ef50b71..10127ad 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,10 +1,13 @@ -const CACHE = 'gree-controller-v055'; -const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json']; +const CACHE = 'gree-controller-v060'; +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('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 => { - 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 => { 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('/'))))); }); diff --git a/web/theme-init.js b/web/theme-init.js new file mode 100644 index 0000000..b5bb25f --- /dev/null +++ b/web/theme-init.js @@ -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; +})();