This commit is contained in:
Mateusz Gruszczyński
2026-08-24 23:11:41 +02:00
parent 4ab878d587
commit 76a6f050ae
24 changed files with 600 additions and 108 deletions
+28
View File
@@ -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.
+24 -18
View File
@@ -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
+1 -1
View File
@@ -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));
}
+32
View File
@@ -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.
+31
View File
@@ -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
View File
@@ -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"
}
}
+32 -2
View File
@@ -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"
}
}
+10 -4
View File
@@ -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() {
+4 -18
View File
@@ -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" <<ENV
GREE_CONTROLLER_BIND=0.0.0.0:8787
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_AUTO_SEED=false
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
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
RUST_LOG=gree_controller=info,tower_http=info
ENV
chmod 0600 "$ENV_FILE"
say "Generated administrator token and saved it to $ENV_FILE"
printf 'Administrator token: %s\n' "$token"
say "Created minimal configuration in $ENV_FILE (authentication disabled by default)"
else
say "Preserving existing $ENV_FILE"
fi
+96 -8
View File
@@ -2,9 +2,9 @@ use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
use axum::{
body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
http::{header, HeaderValue, StatusCode},
http::{header, HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next},
response::{Html, Response},
response::{Redirect, Response},
routing::{get, post},
Json, Router,
};
@@ -15,20 +15,22 @@ use rand::{rngs::OsRng, RngCore};
use serde::Deserialize;
use sha2::{Digest, Sha256};
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 crate::{
engine,
error::AppError,
home_assistant,
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,
state::AppState,
};
const INDEX_HTML: &str = include_str!("../web/index.html");
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 MANIFEST: &str = include_str!("../web/manifest.webmanifest");
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/: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<AppState>, Path(id): Path<String>, 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<AppState>, Json(mut input): Json<Ru
}
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.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.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
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})))
}
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 {
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<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 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") }
+15 -2
View File
@@ -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<Self> {
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<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> {
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
+33 -1
View File
@@ -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(),
}
}
+1
View File
@@ -6,6 +6,7 @@ mod error;
mod home_assistant;
mod influxdb;
mod models;
mod notifications;
mod protocol;
mod queries;
mod state;
+52
View File
@@ -326,6 +326,10 @@ pub struct Zone {
#[serde(default)]
pub demand: bool,
#[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 created_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)]
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,
}
+83
View File
@@ -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()),
}
}
+7
View File
@@ -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; });
}
}
}
+67 -23
View File
@@ -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 `<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('');
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='<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() {
@@ -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 = `<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; });
}
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 => `<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>`;
} 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));
+33 -24
View File
@@ -5,19 +5,10 @@
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#151515" id="themeColorMeta">
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
<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>
<link rel="manifest" href="__GREE_BASE_PATH__/manifest.webmanifest">
<link rel="icon" href="__GREE_BASE_PATH__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__GREE_BASE_PATH__/styles.css">
<script src="__GREE_BASE_PATH__/theme-init.js"></script>
<title>GREE Controller</title>
</head>
<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">
<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">
@@ -260,7 +267,7 @@
<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="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>
</section>
</main>
@@ -336,18 +343,19 @@
<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.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>
<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.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.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
</details>
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="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">
<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>
</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>
<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>
@@ -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>
<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 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>
<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>
@@ -374,14 +382,15 @@
<form method="dialog" id="automationForm" class="dialog-form">
<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="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="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>
<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>
<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.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>
<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>
@@ -409,6 +418,6 @@
<div id="debugOverlayLines" class="debug-overlay-lines"></div>
</aside>
<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>
</html>
+1 -1
View File
@@ -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"}]}
+2
View File
@@ -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}}
+7 -4
View File
@@ -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('/')))));
});
+9
View File
@@ -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;
})();