diff --git a/Cargo.lock b/Cargo.lock index fac4dd8..9aeabb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.14" +version = "0.8.15" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7bcdbd3..1c4877a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.14" +version = "0.8.15" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 2022009..c8bf529 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,11 +1,11 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -67bc978959e4922de1c02cccd853dbd825735a738f23bdc261129c1a57a78c33 ./Cargo.lock -212f4f9307ed6e9b0b8f1db51e4208c8d625663f3974a016797f446caf7386bd ./Cargo.toml +18b9dd7ee3a7ef1f63c8511a90ff6b0dbc6e71c53c0d049b4a764ecd3a1e4beb ./Cargo.lock +0449927a38faccafff1163e6cbdc0fb6f4b3ab7148f477ed0dc8a67e8188fea7 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -2937c57efa02f5a95840e1bae670fb989901b54dde8e89c80035e2ffc5888fda ./README.md -91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs -1194a026091b3bfc12615a31461c065ad010c1962a390e77bb0e4a6bf722a971 ./docs/API.md +c51172d362ed76291c3d4bf213d7717764e2aa71d2a15994cd0569d6aec62e7b ./README.md +ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs +6f7b93c47d62660c470fd410eb687c26af118cc3eb6f2c413d0eabf709e4c363 ./docs/API.md 95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py @@ -16,7 +16,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 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 -70b680a57ac75d6a1ccf10b38ce03b0f01a770cf260e992f63932c92dd7ef2bf ./home-assistant/custom_components/gree_controller/manifest.json +bb37ecd282ddd592d9d515765d90620c7e5e84dccddc24b2cf8a1058f5e4a550 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -25,8 +25,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./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 -0e8218afe7cddfa4519405213e67e5b84bd108c44e0b8bf2155bf94b81949399 ./lang/en.json -7ee310fa91a7be09fe4a8d4ab8ab94d651dbe0c72e512ec07517461b9381731f ./lang/pl.json +1d07d0c32e70574374adaabbfcb59d3bdb4790e80ecdc89397bccf54c25a37e5 ./lang/en.json +9a553a4b4383ed9a349d401f464ec7f2bbf89a082a973e82ba56a49a17907892 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -39,8 +39,8 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -a57c2c473bcabc9973df075b4dd5dbfd2831d13650b5deba96f0a19807ac7f0b ./src/api.rs -d5c233a325231c56d2553d3ad23521bd12d47131dce183a8d2e7e7d817e3e7aa ./src/api/assets.rs +ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs +50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs bc6dbb4b8fb6972ee9065f3caf36881e4316e70f2b7a7d04e7848839fd0801f3 ./src/api/automations.rs 05fdfca0e5d0eca7ace3267c4d95409ef12ead087bb9aad73d5a09b5cc7c3de1 ./src/api/debug_tokens.rs @@ -50,7 +50,7 @@ ffa0a35a0c6badbbbaad88dfaedd46049a0bc4ce2f7e8b930dbdbffe5df4cffb ./src/api/devi a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs f312f8a91d7ce1e60b189e684632edbd68e5496723f49d07aef885699a9f60f3 ./src/api/house.rs 0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs -cf175af8ccde1861c82e58e387df0b0085d3699433a06898824d625457008656 ./src/api/middleware.rs +bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs 2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs eed70af84e9ff9f0ec1b2005836e9ab9aa6a4e73c5c876ec161e5d5c96965b26 ./src/api/schedules.rs a4d0d6c6ffb7944d336a2cf749a27ee5ed966fd94ab29464ce641873783ce756 ./src/api/settings.rs @@ -125,10 +125,25 @@ f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs 2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -91a8075a424c6b11e8613f99970b4cd4abae57b99a3375a34f7288c50f06900e ./web/app.js +de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -fe994148bb0e28027c9ddf5962a4878e2bde6d8158cbac1fb76c8363bea6405e ./web/index.html +76bd4dca7584a7b1f7fc636a0f6db66e44e93a65e81b8784bfb3dfad7f1601a4 ./web/index.html +9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md +b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js +64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js +9f2642f4b2b61fe29866e4e70b7aa3bd1b29c260d5561b2b517ef0e1ed7ae3e5 ./web/js/core.js +86e3556f45ecf10a812986bae30696cb5d5c7c4de24bd8cb539481be3ce5a8d9 ./web/js/dashboard.js +cd42f4061ec4079fad51385d854545b83dfd981a740536f326d029e42a099d5b ./web/js/entities.js +39cb480586bbd5f8bc24833068467c3a59720a64d2bd7c2aa70f02f27413bb0f ./web/js/events.js +89e55155af2392dcc8b0fe56ebe9b0a0a786e430c952cf9aa7e3008354d12ad2 ./web/js/forms.js +be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js +6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js +8f8656de7535c57832905a3bde57a382644acd519646377a35ea3ffa9079f926 ./web/js/navigation.js +d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js +45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js +28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js +9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -934080719827e57bf2bac96b2c5cb799d0b6916f619f394be1e5394bc12bb51e ./web/styles.css -47d57bebd83e4738f5b6c8dbbcb33d125a657b2b0c3ebcfcc2c4f35ab4484326 ./web/sw.js +3a0d6876bc6862d21b1b62601d6a779c282770eaea0ac51dbe492e0ed39fff8d ./web/styles.css +03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 31ddfd4..54d6665 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Current release: 0.8.14** +**Current release: 0.8.15** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. @@ -376,6 +376,7 @@ curl -H 'Authorization: Bearer YOUR_TOKEN' \ ```text src/ Rust backend, GREE protocol and thermostat engine web/ Embedded Web UI / PWA +web/js/ Frontend JS source modules bundled by build.rs lang/ Runtime language packs home-assistant/ Home Assistant custom integration and migration output scripts/ Development, install, update, service and diagnostics @@ -384,7 +385,7 @@ docs/API.md Complete API documentation .env.example Environment reference ``` -The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. +The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. `build.rs` concatenates the ordered files from `web/js/` into one generated application bundle and exposes it under a content-hashed URL. ## Validation before release diff --git a/build.rs b/build.rs index 694a419..8980996 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,5 @@ use serde_json::{json, Value}; -use std::{env, fs, path::PathBuf}; +use std::{env, fs, path::{Path, PathBuf}}; fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str { meta.get(key) @@ -8,11 +8,87 @@ fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str { .unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string")) } + +fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn content_hash(bytes: &[u8]) -> String { + format!("{:016x}", fnv1a_update(0xcbf29ce484222325, bytes)) +} + +const APP_JS_MODULES: &[&str] = &[ + "core.js", + "forms.js", + "bootstrap.js", + "dashboard.js", + "entities.js", + "settings-ui.js", + "router.js", + "navigation.js", + "charts.js", + "history.js", + "realtime.js", + "events.js", + "settings.js", + "main.js", +]; + +fn bundle_app_js(web_dir: &Path) -> String { + let js_dir = web_dir.join("js"); + println!("cargo:rerun-if-changed={}", js_dir.display()); + + let mut bundle = String::new(); + for &filename in APP_JS_MODULES { + let path = js_dir.join(filename); + println!("cargo:rerun-if-changed={}", path.display()); + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); + bundle.push_str(&source); + if !source.ends_with('\n') { + bundle.push('\n'); + } + } + bundle +} + fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let lang_dir = manifest_dir.join("lang"); + let web_dir = manifest_dir.join("web"); println!("cargo:rerun-if-changed={}", lang_dir.display()); + let theme_init_path = web_dir.join("theme-init.js"); + let styles_path = web_dir.join("styles.css"); + let index_path = web_dir.join("index.html"); + let not_found_path = web_dir.join("404.html"); + let sw_path = web_dir.join("sw.js"); + let manifest_path = web_dir.join("manifest.webmanifest"); + let favicon_path = web_dir.join("favicon.svg"); + for path in [&theme_init_path, &styles_path, &index_path, ¬_found_path, &sw_path, &manifest_path, &favicon_path] { + println!("cargo:rerun-if-changed={}", path.display()); + } + + let app_js = bundle_app_js(&web_dir); + let app_js_hash = content_hash(app_js.as_bytes()); + let theme_init_bytes = fs::read(&theme_init_path).unwrap_or_else(|error| panic!("cannot read {}: {error}", theme_init_path.display())); + let styles_bytes = fs::read(&styles_path).unwrap_or_else(|error| panic!("cannot read {}: {error}", styles_path.display())); + let theme_init_hash = content_hash(&theme_init_bytes); + let styles_hash = content_hash(&styles_bytes); + let app_js_asset_path = format!("/app-{}.js", &app_js_hash[..12]); + let theme_init_asset_path = format!("/theme-init-{}.js", &theme_init_hash[..12]); + let styles_asset_path = format!("/styles-{}.css", &styles_hash[..12]); + + let mut build_hash = fnv1a_update(0xcbf29ce484222325u64, app_js.as_bytes()); + for path in [&theme_init_path, &styles_path, &index_path, ¬_found_path, &sw_path, &manifest_path, &favicon_path] { + let bytes = fs::read(path).unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); + build_hash = fnv1a_update(build_hash, &bytes); + } + let mut files: Vec = fs::read_dir(&lang_dir) .unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_dir.display())) .filter_map(Result::ok) @@ -39,6 +115,7 @@ fn main() { let source = fs::read_to_string(&path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); + build_hash = fnv1a_update(build_hash, source.as_bytes()); let document: Value = serde_json::from_str(&source) .unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}")); let root_object = document.as_object().unwrap_or_else(|| panic!("{filename}: language pack root must be an object")); @@ -85,6 +162,10 @@ fn main() { let mut generated = String::new(); generated.push_str("// @generated by build.rs - do not edit.\n"); + generated.push_str(&format!("pub const APP_JS_ASSET_PATH: &str = {:?};\n", app_js_asset_path)); + generated.push_str(&format!("pub const THEME_INIT_ASSET_PATH: &str = {:?};\n", theme_init_asset_path)); + generated.push_str(&format!("pub const STYLES_CSS_ASSET_PATH: &str = {:?};\n", styles_asset_path)); + generated.push_str(&format!("pub const ASSET_BUILD_ID: &str = \"{:016x}\";\n", build_hash)); generated.push_str(&format!("pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", manifest_json)); generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n"); for (code, path) in assets { @@ -97,5 +178,6 @@ fn main() { generated.push_str("];\n"); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + fs::write(out_dir.join("app.bundle.js"), app_js).expect("write generated app.bundle.js"); fs::write(out_dir.join("languages.rs"), generated).expect("write generated languages.rs"); } diff --git a/docs/API.md b/docs/API.md index d92c475..0b764b6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.8.14**. +HTTP and WebSocket API for GREE Controller **0.8.15**. [← Main documentation](../README.md) @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.8.14", + "version": "0.8.15", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -236,7 +236,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.8.14", + "version": "0.8.15", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 8cfaafe..42127a7 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.14", + "version": "0.8.15", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 2bcef7d..2b7c5ab 100644 --- a/lang/en.json +++ b/lang/en.json @@ -705,7 +705,7 @@ "logs.days90": "90 days", "logs.days180": "180 days", "logs.days365": "365 days", - "history.hoverHint": "Hover, tap or use ←/→ for exact values. Use +/− to zoom and swipe to pan.", + "history.hoverHint": "Hover or tap for exact values. Drag-select an area to zoom; pinch on mobile. Click legend items to hide or show metrics.", "actions.saving": "Saving…", "actions.retry": "Retry", "auth.connecting": "Connecting…", @@ -717,6 +717,8 @@ "validation.invalid": "Check this value.", "validation.entityId": "Use a Home Assistant entity_id such as sensor.living_room_temperature.", "validation.fixFields": "Correct the highlighted fields before saving.", + "history.legendShow": "Show metric: {name}", + "history.legendHide": "Hide metric: {name}", "history.zoomControls": "Chart zoom", "history.zoomOut": "Zoom out", "history.zoomReset": "Reset zoom", @@ -735,20 +737,12 @@ "settings.greeConnection": "GREE connection", "settings.greeConnectionHint": "Controller identity, polling intervals and LAN discovery transport.", "settings.systemStatusTitle": "Controller health", - "settings.systemStateHint": "Live runtime, connectivity and storage details useful during setup and diagnostics.", + "settings.systemStateHint": "Application process, control engine, live connection and local runtime status.", "settings.controlEngine": "Control engine", "settings.ready": "Ready", "settings.syncing": "Synchronizing", "settings.websocket": "Live connection", - "settings.runtimeMode": "Runtime mode", - "settings.runtimeSimulation": "Simulation", - "settings.runtimeLive": "Live devices", - "settings.devicesConfigured": "Configured devices", - "settings.devicesOnline": "Devices online", - "settings.simulatedDevices": "Simulated devices", - "settings.receivedFrames": "GREE frames received", "settings.httpBind": "HTTP bind", - "settings.greeInterface": "GREE interface", "settings.database": "Database", "settings.basePath": "Base path", "settings.healthy": "Healthy", @@ -760,14 +754,6 @@ "debug.emptyRequests": "No HTTP requests captured yet.", "debug.emptyGree": "No GREE frames captured yet.", "simulation.bannerTitle": "SIMULATION MODE", - "simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data.", - "settings.pollCycle": "Device poll interval", - "settings.zoneCycle": "Zone control interval", - "settings.homeAssistantStatus": "Home Assistant", - "settings.influxStatus": "InfluxDB history", - "settings.notificationsStatus": "Notifications", - "settings.debugStatus": "Debug overlay", - "settings.configured": "configured", - "settings.notConfigured": "not configured" + "simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data." } } diff --git a/lang/pl.json b/lang/pl.json index a5473ef..d1e64e2 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -705,7 +705,7 @@ "logs.days90": "90 dni", "logs.days180": "180 dni", "logs.days365": "365 dni", - "history.hoverHint": "Najedź, dotknij lub użyj ←/→, aby zobaczyć dokładne wartości. Użyj +/− do powiększenia i przesuń wykres palcem.", + "history.hoverHint": "Najedź lub dotknij, aby zobaczyć wartości. Zaznacz myszką obszar, aby go przybliżyć; na telefonie użyj szczypania. Klikaj legendę, aby ukrywać i pokazywać metryki.", "actions.saving": "Zapisywanie…", "actions.retry": "Ponów", "auth.connecting": "Łączenie…", @@ -717,6 +717,8 @@ "validation.invalid": "Sprawdź tę wartość.", "validation.entityId": "Podaj entity_id Home Assistanta, np. sensor.salon_temperature.", "validation.fixFields": "Popraw zaznaczone pola przed zapisaniem.", + "history.legendShow": "Pokaż metrykę: {name}", + "history.legendHide": "Ukryj metrykę: {name}", "history.zoomControls": "Powiększenie wykresu", "history.zoomOut": "Pomniejsz wykres", "history.zoomReset": "Przywróć powiększenie", @@ -735,20 +737,12 @@ "settings.greeConnection": "Połączenie GREE", "settings.greeConnectionHint": "Identyfikator kontrolera, interwały odczytu i transport wykrywania urządzeń w LAN.", "settings.systemStatusTitle": "Kondycja kontrolera", - "settings.systemStateHint": "Bieżące informacje o pracy, połączeniu i pamięci danych przydatne podczas konfiguracji i diagnostyki.", + "settings.systemStateHint": "Stan procesu aplikacji, silnika sterowania, połączenia oraz lokalnego środowiska uruchomieniowego.", "settings.controlEngine": "Silnik sterowania", "settings.ready": "Gotowy", "settings.syncing": "Synchronizacja", "settings.websocket": "Połączenie na żywo", - "settings.runtimeMode": "Tryb pracy", - "settings.runtimeSimulation": "Symulacja", - "settings.runtimeLive": "Urządzenia fizyczne", - "settings.devicesConfigured": "Skonfigurowane urządzenia", - "settings.devicesOnline": "Urządzenia online", - "settings.simulatedDevices": "Urządzenia symulowane", - "settings.receivedFrames": "Odebrane ramki GREE", "settings.httpBind": "Nasłuch HTTP", - "settings.greeInterface": "Interfejs GREE", "settings.database": "Baza danych", "settings.basePath": "Ścieżka bazowa", "settings.healthy": "Działa prawidłowo", @@ -760,14 +754,6 @@ "debug.emptyRequests": "Brak przechwyconych requestów HTTP.", "debug.emptyGree": "Brak przechwyconych ramek GREE.", "simulation.bannerTitle": "TRYB SYMULACJI", - "simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe.", - "settings.pollCycle": "Interwał odczytu urządzeń", - "settings.zoneCycle": "Interwał regulatora stref", - "settings.homeAssistantStatus": "Home Assistant", - "settings.influxStatus": "Historia InfluxDB", - "settings.notificationsStatus": "Powiadomienia", - "settings.debugStatus": "Panel debug", - "settings.configured": "skonfigurowany", - "settings.notConfigured": "nieskonfigurowany" + "simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe." } } diff --git a/src/api.rs b/src/api.rs index bc14bc7..7633380 100644 --- a/src/api.rs +++ b/src/api.rs @@ -29,7 +29,8 @@ use crate::{ }; const INDEX_HTML: &str = include_str!("../web/index.html"); -const APP_JS: &str = include_str!("../web/app.js"); +const NOT_FOUND_HTML: &str = include_str!("../web/404.html"); +const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.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"); @@ -37,6 +38,26 @@ const SERVICE_WORKER: &str = include_str!("../web/sw.js"); const FAVICON: &str = include_str!("../web/favicon.svg"); include!(concat!(env!("OUT_DIR"), "/languages.rs")); +const SPA_ROUTES: &[&str] = &[ + "/dashboard", + "/devices", + "/zones", + "/groups", + "/schedules", + "/automations", + "/simulation", + "/night-mode", + "/home-assistant", + "/settings", + "/events", + "/history", + "/history/overview", + "/history/zones", + "/history/devices", + "/history/sensors", + "/history/custom", +]; + pub fn router(state: AppState) -> Router { let protected = Router::new() .route("/api/bootstrap", get(bootstrap)) @@ -88,23 +109,29 @@ pub fn router(state: AppState) -> Router { .route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control)) .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); - let app = Router::new() + let mut 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(APP_JS_ASSET_PATH, get(app_js)) + .route(THEME_INIT_ASSET_PATH, get(theme_init_js)) + .route(STYLES_CSS_ASSET_PATH, get(styles_css)) + .route("/app.js", get(app_js_legacy)) + .route("/theme-init.js", get(theme_init_js_legacy)) + .route("/styles.css", get(styles_css_legacy)) .route("/manifest.webmanifest", get(manifest)) .route("/sw.js", get(service_worker)) .route("/favicon.svg", get(favicon)) .route("/lang/index.json", get(language_index)) .route("/lang/:file", get(language_file)) .merge(protected) - .merge(home_assistant_api) - .fallback(index) - ; + .merge(home_assistant_api); + + for &route in SPA_ROUTES { + app = app.route(route, get(index)); + } + let app = app.fallback(not_found); let app = if state.config.base_path.is_empty() { app @@ -117,6 +144,7 @@ pub fn router(state: AppState) -> Router { async move { Redirect::permanent(&redirect_to) } })) .nest(&base, app) + .fallback(not_found) }; app diff --git a/src/api/assets.rs b/src/api/assets.rs index 800015a..98cdc6c 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -1,27 +1,60 @@ +async fn not_found(State(state): State, headers: HeaderMap) -> Response { + let base = request_base_path(&state, &headers); + let home = if base.is_empty() { "/".to_owned() } else { format!("{base}/") }; + let body = NOT_FOUND_HTML + .replace("__GREE_BASE_PATH__", &base) + .replace("__GREE_HOME_PATH__", &home) + .replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}")) + .replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}")); + let mut response = Response::new(Body::from(body)); + *response.status_mut() = StatusCode::NOT_FOUND; + 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("private, no-store, no-cache, must-revalidate")); + response +} + 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 base = request_base_path(&state, &headers); + let body = INDEX_HTML + .replace("__GREE_BASE_PATH__", &base) + .replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}")) + .replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}")) + .replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}")); 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.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate")); response } +fn request_base_path(state: &AppState, headers: &HeaderMap) -> String { + if !state.config.base_path.is_empty() { + state.config.base_path.clone() + } else { + forwarded_prefix(headers).unwrap_or_default() + } +} + 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 app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") } +async fn app_js_legacy() -> 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=31536000, immutable") } +async fn theme_init_js_legacy() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "no-cache") } +async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "public, max-age=31536000, immutable") } +async fn styles_css_legacy() -> 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") } +async fn service_worker() -> Response { + let body = SERVICE_WORKER + .replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID) + .replace("__GREE_APP_ASSET__", APP_JS_ASSET_PATH) + .replace("__GREE_THEME_INIT_ASSET__", THEME_INIT_ASSET_PATH) + .replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH); + owned_response(body, "application/javascript; charset=utf-8", "no-cache") +} async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") } async fn language_index() -> Response { static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") @@ -42,3 +75,10 @@ fn static_response(body: &'static str, content_type: &'static str, cache: &'stat response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); response } + +fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response { + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + response +} diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 0d73c23..f533987 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -1,13 +1,24 @@ 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 is_html = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.split(';').next().is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html"))); + 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")); } + + if is_html { + headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN")); + 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 } - diff --git a/web/404.html b/web/404.html new file mode 100644 index 0000000..a84165c --- /dev/null +++ b/web/404.html @@ -0,0 +1,32 @@ + + + + + + + + + + + Page not found · GREE Controller + + +
+
+
GREE Controller
+
+
+ +
+
+ Navigation error + +

Page not found

+

The address may be incorrect or the page may no longer exist.

+ +
+
+ + diff --git a/web/app.js b/web/app.js deleted file mode 100644 index 2920756..0000000 --- a/web/app.js +++ /dev/null @@ -1,3175 +0,0 @@ -'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=${APP_BASE || '/'}; SameSite=Lax`; -}; - -const DEFAULT_LANGUAGE = 'en'; -const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE; -const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system'; - -const app = { - devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, - token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, - currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', - languages: [], translations: {}, locales: {}, - historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, - historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, - customChartSeries: [], savedCharts: [], chartZooms: {}, zoneControlSeq: {}, zoneTemperatureTimers: {}, - controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, - simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), -}; -try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } - -const $ = (selector, root = document) => root.querySelector(selector); -const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; -const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); -const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); -const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB'; -const tr = (key, params = {}) => { - const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key; - return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`); -}; -const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; -const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value); -const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); -const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode); -const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); -const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; -const localResumeSeconds = value => { - const timestamp = value ? new Date(value).getTime() : NaN; - return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null; -}; -const formatCountdown = seconds => { - if (!Number.isFinite(seconds)) return '--:--'; - const safe = Math.max(0, Math.floor(seconds)); - const minutes = Math.floor(safe / 60); - const remainder = safe % 60; - return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; -}; -const formatExtendedCountdown = seconds => { - if (!Number.isFinite(seconds)) return '∞'; - const safe = Math.max(0, Math.floor(seconds)); - const hours = Math.floor(safe / 3600); - const minutes = Math.floor((safe % 3600) / 60); - const remainder = safe % 60; - return hours > 0 - ? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}` - : `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; -}; -function updateLocalThermostatCountdowns() { - $$('[data-local-resume-countdown]').forEach(node => { - const remaining = localResumeSeconds(node.dataset.localResumeCountdown); - node.textContent = remaining !== null && remaining > 0 - ? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)}) - : tr('zones.localThermostatResuming'); - }); -} -function temporarySessionStatus(zone) { - const session = zone?.temporary_quick_thermostat; - if (!session) return null; - const now = Date.now(); - const secondsUntil = value => { - const timestamp = value ? new Date(value).getTime() : NaN; - return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null; - }; - const safetyRemaining = secondsUntil(session.safety_expires_at); - const hardRemaining = secondsUntil(session.expires_at); - const startRemaining = secondsUntil(session.started_at); - const pending = !session.activated_at; - const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint); - const targetText = Number.isFinite(target) ? target.toFixed(1) : '--'; - const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1); - const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort'; - const condition = tr(operatorKey, {target:targetText, tolerance}); - - if (session.state === 'waiting_master') { - return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true}; - } - if (session.state === 'paused_manual') { - return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending}; - } - if (pending) { - return { - countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00', - detail:startRemaining != null && startRemaining > 0 - ? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)}) - : tr('zones.temporaryStartingStatus'), - kind:session.finish_kind, - pending:true, - }; - } - - if (session.finish_kind === 'temperature_stable') { - const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN; - const holdSeconds = Number(session.hold_seconds || 0); - if (Number.isFinite(started)) { - const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000)); - const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining); - return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind}; - } - return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind}; - } - if (session.finish_kind === 'temperature_reached') { - return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind}; - } - const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus'; - return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind}; -} -function updateTemporaryThermostatCountdowns() { - $$('[data-temporary-countdown-zone]').forEach(node => { - const zone = app.zones.find(item => item.id === node.dataset.temporaryCountdownZone); - const status = temporarySessionStatus(zone); - if (!status) return; - node.textContent = status.countdown; - node.title = status.detail; - }); - const dialog = $('#temporaryThermostatDialog'); - if (dialog?.open) { - const zone = app.zones.find(item => item.id === $('#temporaryThermostatForm')?.elements.zone_id.value); - const status = temporarySessionStatus(zone); - if (status) { - $('#temporaryThermostatActiveCountdown').textContent = status.countdown; - $('#temporaryThermostatActiveDetail').textContent = status.detail; - const label = $('#temporaryThermostatActiveLabel'); - if (label) label.textContent = tr(status.pending ? 'zones.temporaryScheduled' : 'zones.temporaryActive'); - } - } -} -const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; - -function syncTopbarHeight() { - const topbar = $('.topbar'); - if (!topbar) return; - document.documentElement.style.setProperty('--topbar-height', `${Math.ceil(topbar.getBoundingClientRect().height)}px`); -} - -function observeTopbarHeight() { - const topbar = $('.topbar'); - if (!topbar) return; - syncTopbarHeight(); - if ('ResizeObserver' in window) { - const observer = new ResizeObserver(syncTopbarHeight); - observer.observe(topbar); - } else { - window.addEventListener('resize', syncTopbarHeight); - } -} - -function updateConnectionIndicator(status) { - app.connectionStatus = status || 'connecting'; - const node = $('#connectionLabel'); - const label = tr(`status.${app.connectionStatus}`); - if (node) { - node.className = `connection-dot ${app.connectionStatus}`; - node.setAttribute('aria-label', label); - node.title = label; - } - const banner = $('#connectionBanner'); - const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus); - document.body.classList.toggle('connection-lost', disconnected); - if (banner) { - banner.hidden = !disconnected; - const title = $('#connectionBannerTitle'); - const text = $('#connectionBannerText'); - if (title) title.textContent = label; - if (text) text.textContent = tr('status.offlineHint'); - } - renderSystemInfo(); -} - -function updateToolbarControls() { - const languageLabel = $('#languageLabel'); - if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase(); - const themeSelect = $('#themeSelect'); - const themeIcon = $('#themeIcon'); - const icons = {system:'◐',light:'☀',dark:'☾'}; - if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system; - if (themeSelect) { - themeSelect.value = app.theme; - const label = `${tr('controls.theme')}: ${tr(`theme.${app.theme}`)}`; - themeSelect.setAttribute('aria-label', label); - themeSelect.closest('.toolbar-picker')?.setAttribute('title', label); - } -} - -function applyTheme() { - const resolved = app.theme === 'light' || app.theme === 'dark' - ? app.theme - : (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); - document.documentElement.dataset.theme = resolved; - const meta = $('#themeColorMeta'); - if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515'; - updateToolbarControls(); - drawCurrentChartIfVisible(); -} - -function applyTranslations() { - document.documentElement.lang = app.language; - $$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); }); - $$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); }); - $$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); }); - $$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); }); - $$('[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; - updateToolbarControls(); - updateConnectionIndicator(app.connectionStatus); - renderAll(); - if (app.currentView === 'logs') loadLogs(); - if (app.currentView === 'history' && app.zones.length) loadHistory(); -} - -function setLanguage(language) { - const available = app.languages.some(item => item.code === language); - app.language = available ? language : DEFAULT_LANGUAGE; - setCookie('gree_controller_language', app.language); - applyTranslations(); -} - -function renderLanguageOptions() { - const select = $('#languageSelect'); - if (!select) return; - select.innerHTML = app.languages.map(item => { - const label = item.native_name || item.name || item.code.toUpperCase(); - return ``; - }).join(''); - select.value = app.language; - updateToolbarControls(); -} - -async function loadLanguages() { - try { - 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(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}; - })); - - app.languages = loaded.map(({item}) => item); - app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}])); - app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code])); - app.language = app.languages.some(item => item.code === preferredLanguage) - ? preferredLanguage - : (manifest.default || DEFAULT_LANGUAGE); - if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE; - renderLanguageOptions(); - } catch (error) { - console.error('Unable to load language packs:', error); - app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}]; - app.translations = {[DEFAULT_LANGUAGE]: {}}; - app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'}; - app.language = DEFAULT_LANGUAGE; - renderLanguageOptions(); - } -} - -function setTheme(theme) { - app.theme = ['system', 'light', 'dark'].includes(theme) ? theme : 'system'; - setCookie('gree_controller_theme', app.theme); - applyTheme(); -} - -function cycleTheme() { - const modes = ['system', 'light', 'dark']; - const index = modes.indexOf(app.theme); - setTheme(modes[(index + 1) % modes.length]); -} - -async function api(path, options = {}) { - const headers = new Headers(options.headers || {}); - headers.set('Accept', 'application/json'); - if (app.token) headers.set('Authorization', `Bearer ${app.token}`); - let body = options.body; - if (body !== undefined && body !== null && typeof body !== 'string') { - headers.set('Content-Type', 'application/json'); - body = JSON.stringify(body); - } - const response = await fetch(withBase(path), {...options, headers, body}); - if (response.status === 401) { - showTokenDialog(); - throw new Error(tr('auth.invalid')); - } - if (!response.ok) { - let message = tr('error.http', {status: response.status}); - try { message = (await response.json()).error || message; } catch (_) {} - throw new Error(message); - } - if (response.status === 204) return null; - return response.json(); -} - -function toast(message, error = false) { - const host = $('#toastStack'); if (!host) return; - const item = document.createElement('div'); - item.className = `toast-item ${error ? 'error' : 'success'}`; - item.innerHTML = `${error ? '!' : '✓'}
${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
`; - host.appendChild(item); - requestAnimationFrame(() => item.classList.add('show')); - const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); }; - item.querySelector('.toast-close').addEventListener('click', remove); - item._timer = setTimeout(remove, error ? 5200 : 3600); -} - -const cleanFormSnapshots = new WeakMap(); - -function formSnapshot(form) { - if (!form) return ''; - const rows = []; - $$('input, select, textarea', form).forEach((field, index) => { - if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return; - const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`; - const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? ''); - rows.push([key, value, String(field.disabled)]); - }); - return JSON.stringify(rows); -} - -function markFormClean(form) { - if (!form) return; - cleanFormSnapshots.set(form, formSnapshot(form)); - form.classList.remove('has-unsaved-changes'); -} - -function updateDirtyIndicator(form) { - if (!form || !cleanFormSnapshots.has(form)) return; - form.classList.toggle('has-unsaved-changes', isFormDirty(form)); -} - -function isFormDirty(form) { - if (!form || !cleanFormSnapshots.has(form)) return false; - return cleanFormSnapshots.get(form) !== formSnapshot(form); -} - -function clearFormErrors(form) { - if (!form) return; - $$('.field-error, .form-error-summary', form).forEach(node => node.remove()); - $$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid')); - $$('.has-error', form).forEach(node => node.classList.remove('has-error')); -} - -function showFieldError(field, message) { - if (!field || !message) return; - field.setAttribute('aria-invalid', 'true'); - const label = field.closest('label'); - if (label) label.classList.add('has-error'); - const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null; - if (existing) { existing.textContent = message; return; } - const note = document.createElement('small'); - note.className = 'field-error'; - note.textContent = message; - field.insertAdjacentElement('afterend', note); -} - -function showFormError(form, message) { - if (!form || !message) return; - const summary = document.createElement('div'); - summary.className = 'form-error-summary'; - summary.setAttribute('role', 'alert'); - summary.textContent = message; - const anchor = $('.settings-save-bar', form) || $('.form-actions', form); - if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor); - else form.appendChild(summary); -} - -function validationMessageFor(field) { - if (field.validity?.valueMissing) return tr('validation.required'); - if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid'); - if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) { - return tr('validation.range', {min:field.min || '—', max:field.max || '—'}); - } - if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid'); - return field.validity?.valid === false ? tr('validation.invalid') : ''; -} - -function httpUrlValid(value) { - if (!String(value || '').trim()) return true; - try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; } -} - -function entityIdValid(value) { - return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim()); -} - -function validateDecimalField(form, name, min, max) { - const field = form?.elements?.[name]; - if (!field || field.disabled) return true; - const value = parseDecimal(field.value); - if (!Number.isFinite(value) || value < min || value > max) { - showFieldError(field, tr('validation.range', {min, max})); - return false; - } - return true; -} - -function validateForm(form) { - if (!form) return true; - clearFormErrors(form); - let valid = true; - $$('input, select, textarea', form).forEach(field => { - if (field.disabled || field.type === 'hidden' || field.type === 'file') return; - const message = validationMessageFor(field); - if (message) { showFieldError(field, message); valid = false; } - }); - - if (form.id === 'homeAssistantForm') { - const url = form.elements.ha_url; - if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } - ['ha_entity_id','ha_outdoor_entity_id'].forEach(name => { - const field = form.elements[name]; - if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; } - }); - } - - if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) { - const url = form.elements.influx_url; - if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; } - else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } - } - - if (form.id === 'zoneForm') { - const source = form.elements.sensor_source?.value; - const entity = form.elements.ha_entity_id; - if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) { - showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required')); - valid = false; - } - [ - ['cool_comfort_setpoint',8,30],['cool_sleep_setpoint',8,30],['cool_away_setpoint',8,30], - ['heat_comfort_setpoint',8,30],['heat_sleep_setpoint',8,30],['heat_away_setpoint',8,30], - ['hysteresis',0.1,5],['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8] - ].forEach(([name,min,max]) => { if (!validateDecimalField(form,name,min,max)) valid = false; }); - } - - if (!valid) { - showFormError(form, tr('validation.fixFields')); - const first = $('[aria-invalid="true"]', form); - const pane = first?.closest?.('[data-settings-pane]'); - if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); - first?.focus({preventScroll:true}); - first?.scrollIntoView({behavior:'smooth', block:'center'}); - } - return valid; -} - -function apiErrorField(form, message) { - const text = String(message || '').toLowerCase(); - const candidates = []; - if (text.includes('influx')) candidates.push('influx_url'); - if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url'); - if (text.includes('entity')) candidates.push('ha_entity_id','ha_outdoor_entity_id'); - if (text.includes('device')) candidates.push('device_id','action_device_id','trigger_device_id'); - if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature','cool_comfort_setpoint','action_target_temperature'); - if (text.includes('name')) candidates.push('name','controller_id'); - if (text.includes('token')) candidates.push('ha_token','influx_token','pushover_app_token'); - if (text.includes('url')) candidates.push('ha_url','influx_url','slack_webhook_url','discord_webhook_url'); - return candidates.map(name => form?.elements?.[name]).find(Boolean) || null; -} - -function presentFormError(form, error) { - clearFormErrors(form); - const message = error?.message || tr('validation.invalid'); - const field = apiErrorField(form, message); - if (field) showFieldError(field, message); - showFormError(form, message); - if (field) { - const pane = field.closest?.('[data-settings-pane]'); - if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); - field.focus({preventScroll:true}); - field.scrollIntoView({behavior:'smooth', block:'center'}); - } - toast(message, true); -} - -function setFormBusy(form, busy, busyKey = 'actions.saving') { - if (!form) return; - const buttons = $$('button[type="submit"]', form); - if (busy) { - form.dataset.submitting = 'true'; - form.setAttribute('aria-busy', 'true'); - form.classList.add('is-saving'); - buttons.forEach(button => { - if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML; - button.disabled = true; - button.innerHTML = `${esc(tr(busyKey))}`; - }); - } else { - delete form.dataset.submitting; - form.removeAttribute('aria-busy'); - form.classList.remove('is-saving'); - buttons.forEach(button => { - button.disabled = false; - if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; } - }); - } -} - -async function runFormTask(form, task, {busyKey='actions.saving', validate=true}={}) { - if (!form || form.dataset.submitting === 'true') return {ok:false, duplicate:true}; - if (validate && !validateForm(form)) return {ok:false, validation:true}; - clearFormErrors(form); - setFormBusy(form, true, busyKey); - try { - const value = await task(); - markFormClean(form); - return {ok:true, value}; - } catch (error) { - presentFormError(form, error); - return {ok:false, error}; - } finally { - setFormBusy(form, false); - } -} - -function restoreTrackedForm(form) { - if (!form) return; - if (form.id === 'settingsForm') renderSettings(); - else if (form.id === 'nightModeForm') renderNightSettings(); - else if (form.id === 'homeAssistantForm') { - app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; - renderHomeAssistantSettings(); - } else markFormClean(form); -} - -function confirmDiscardForm(form) { - if (!isFormDirty(form)) return true; - if (!confirm(tr('confirm.discardChanges'))) return false; - restoreTrackedForm(form); - return true; -} - -function activeDirtySettingsForm() { - const active = $('.view.active'); - if (!active) return null; - const form = $('form.settings-form', active); - return form && isFormDirty(form) ? form : null; -} - -function canLeaveCurrentView(nextName) { - if (nextName === app.currentView) return true; - const form = activeDirtySettingsForm(); - return !form || confirmDiscardForm(form); -} - -function requestDialogClose(dialog) { - if (!dialog) return true; - const form = $('form', dialog); - if (form && !confirmDiscardForm(form)) return false; - dialog.close(); - return true; -} - -function setupFormUx() { - $$('form').forEach(form => { - form.noValidate = true; - if (!cleanFormSnapshots.has(form)) markFormClean(form); - }); -} - -document.addEventListener('input', event => { - const field = event.target.closest?.('input, select, textarea'); - const form = field?.form; - if (!field || !form) return; - if (field.getAttribute('aria-invalid') === 'true') { - field.removeAttribute('aria-invalid'); - field.closest('label')?.classList.remove('has-error'); - if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove(); - $('.form-error-summary', form)?.remove(); - } - updateDirtyIndicator(form); -}); -document.addEventListener('change', event => updateDirtyIndicator(event.target?.form)); - -window.addEventListener('beforeunload', event => { - const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form)); - if (!dirty) return; - event.preventDefault(); - event.returnValue = ''; -}); - -document.addEventListener('cancel', event => { - const dialog = event.target.closest?.('dialog'); - if (!dialog) return; - const form = $('form', dialog); - if (!form || !isFormDirty(form)) return; - event.preventDefault(); - if (confirmDiscardForm(form)) dialog.close(); -}, true); - -function showTokenDialog() { - const dialog = $('#tokenDialog'); - if (!dialog.open) dialog.showModal(); -} - -async function loadBootstrap(showMessage = false) { - if (app.loading) return; - app.loading = true; - try { - const data = await api('/api/bootstrap'); - app.devices = data.devices || []; - app.zones = data.zones || []; - app.groups = data.groups || []; - app.schedules = data.schedules || []; - app.automations = data.automations || []; - app.accessTokens = data.access_tokens || []; - app.settings = data.settings || null; - app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; - app.system = data.system || {}; - app.systemSnapshotAt = Date.now(); - app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; - renderAll(); - loadControlPlan(); - if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); - if (showMessage) toast(tr('common.updated')); - if ($('#tokenDialog').open) $('#tokenDialog').close(); - connectWebSocket(); - } catch (error) { - if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true); - } finally { - app.loading = false; - } -} - -function renderAll() { - renderSummary(); - renderHouseClimate(); - renderGroups(); - renderControlPlan(); - renderSimulationPage(); - renderDevices(); - renderZones(); - renderSchedules(); - renderAutomations(); - renderAccessTokens(); - fillSelects(); - renderSettings(); - renderNightSettings(); - renderHomeAssistantSettings(); - renderSimulationModeBanner(); - renderDebugOverlay(); -} - -function renderSummary() { - const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite); - const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null; - const online = app.devices.filter(d => d.online).length; - const active = app.devices.filter(d => d.power).length; - const demand = app.zones.filter(z => z.enabled && z.demand).length; - const toolbarStatus = $('#toolbarRuntimeStatus'); - if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', {active, total:app.devices.length}); - $('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}°C`; - const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : ''; - $('#summaryText').textContent = app.devices.length - ? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix}) - : tr('dashboard.empty'); - $('#metrics').innerHTML = [ - [tr('dashboard.metricOnline'), `${online}/${app.devices.length}`], - [tr('dashboard.metricActive'), active], - [tr('dashboard.metricDemand'), demand], - ].map(([label,value]) => `
${esc(label)}${esc(value)}
`).join(''); - renderSystemInfo(); -} - -function renderHouseClimate() { - const node = $('#houseClimate'); if (!node || !app.settings) return; - const mode = app.settings.house_mode || 'cool'; - const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto'); - const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null; - const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature); - const powerEnabled = app.settings.house_power_enabled !== false; - const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff'); - if (powerOn) { powerOn.classList.toggle('active', powerEnabled); powerOn.setAttribute('aria-pressed', String(powerEnabled)); } - if (powerOff) { powerOff.classList.toggle('active', !powerEnabled); powerOff.setAttribute('aria-pressed', String(!powerEnabled)); } - node.innerHTML = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

${esc(tr('house.outdoor'))}${esc(outdoor)}
-
- - - -
-
${['auto','comfort','sleep','away'].map(p=>``).join('')}
`; -} - -function planEventMarkup(event) { - const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—'; - const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`; - return `
  • ${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}
  • `; -} - -function automationTriggerLabel(item) { - if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'}); - const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow'; - return tr(key, {temperature: fmtTemp(item.threshold)}); -} - -function planZoneEffectiveEnabled(zone) { - if (!zone) return undefined; - return zone.effective_enabled ?? zone.enabled ?? false; -} - -function renderControlPlan() { - const host = $('#controlPlan'); if (!host) return; - const plan = app.controlPlan; - if (!plan) { - host.innerHTML = `
    ${esc(tr('plan.loading'))}
    `; - return; - } - - const allZones = plan.zones || []; - const houseEvents = (plan.next_events || []).slice(0, 3); - const house = `
    ${esc(tr('plan.house'))}

    ${esc(houseModeLabel(plan.house_mode || 'off'))}

    ${esc((plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint'))}

    ${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}

      ${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `
    • ${esc(tr('plan.noEvents'))}
    • `}
    `; - - const groupCards = (app.groups || []).map(group => { - const memberIds = new Set(group.zone_ids || []); - const members = allZones.filter(zone => memberIds.has(zone.zone_id)); - const state = groupState(group); - const powerEnabled = group.power_enabled !== false; - const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length; - const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode); - const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset); - const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`}))) - .sort((a,b) => new Date(a.at) - new Date(b.at)).slice(0, 2); - return `
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(powerEnabled ? tr('common.on') : tr('common.off'))}
    ${esc(mode)}·${esc(preset)}

    ${esc(tr('plan.groupSummary', {zones:members.length, demand}))}

      ${events.length ? events.map(planEventMarkup).join('') : `
    • ${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}
    • `}
    `; - }).join(''); - - const zoneGroups = new Map(); - (app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => { - const names = zoneGroups.get(zoneId) || []; - names.push(group.name); - zoneGroups.set(zoneId, names); - })); - const zones = allZones.map(zone => { - const events = (zone.next_events || []).slice(0, 2); - const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1); - const groupNames = zoneGroups.get(zone.zone_id) || []; - const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice')); - const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off'); - return `
    ${esc(scope)}

    ${esc(zone.zone_name)}

    ${esc(planState)}
    ${fmtTemp(zone.current_temperature)}${esc(target)}°C

    ${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

      ${events.length ? events.map(planEventMarkup).join('') : `
    • ${esc(tr('plan.noEvents'))}
    • `}
    `; - }).join(''); - - const rules = (plan.rules || []).filter(rule => rule.enabled); - const ruleCard = rules.length ? `
    ${esc(tr('plan.rules'))}

    ${esc(tr('plan.ruleCount', {count:rules.length}))}

      ${rules.slice(0,3).map(rule=>`
    • ${esc(rule.name)} → ${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}
    • `).join('')}
    ` : ''; - host.innerHTML = house + groupCards + zones + ruleCard; -} -async function loadControlPlan() { - try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); } - catch (error) { console.warn('Unable to load control plan:', error); } -} - -function simulationTime(value, options = {}) { - if (!value) return '—'; - const date = new Date(value); - return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', ''); -} - -function simulationNumeric(value) { - const n = Number(value); - return Number.isFinite(n) ? n : null; -} - -function simulationOutdoorAssist(mode, outdoor, room, target) { - if (outdoor == null || room == null || target == null) return 0; - const roomError = Math.abs(room - target); - const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1); - return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1); -} - -function simulationRoundDeviceSetpoint(mode, demand, value) { - const safe = clamp(Number(value) || 0, 16, 30); - if (mode === 'heat' && demand) return Math.ceil(safe); - if (mode === 'heat' && !demand) return Math.floor(safe); - if (mode !== 'heat' && demand) return Math.floor(safe); - return Math.ceil(safe); -} - -function simulationSmartFanSpeed(mode, room, target, outdoor, demand) { - if (!demand) return 1; - const error = Math.abs(room - target); - const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32; - if (error >= 2 || extremeWeather) return 3; - if (error >= 1) return 2; - return 0; -} - -function simulationStatusInfo(status) { - const map = { - disabled: {label: tr('common.disabled'), badge: ''}, - off: {label: tr('house.noControl'), badge: ''}, - waiting: {label: tr('simulation.waitingForData'), badge: ''}, - demand: {label: tr('simulation.stateDemand'), badge: 'active'}, - satisfied: {label: tr('simulation.stateSatisfied'), badge: ''}, - }; - return map[status] || {label: status, badge: ''}; -} - -function buildZoneSimulation(zone, planZone) { - const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id)); - const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off'; - const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature); - const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint); - const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature); - const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1); - const half = hysteresis / 2; - let status = 'waiting'; - let demand = false; - if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled'; - else if (mode === 'off') status = 'off'; - else if (current == null || target == null) status = 'waiting'; - else { - if (mode === 'heat') { - if (current <= target - half) demand = true; - else if (current >= target + half) demand = false; - else demand = !!zone?.demand; - } else { - if (current >= target + half) demand = true; - else if (current <= target - half) demand = false; - else demand = !!zone?.demand; - } - status = demand ? 'demand' : 'satisfied'; - } - const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5); - const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target); - const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist); - const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset); - const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget); - const nightActive = !!app.controlPlan?.night_mode_active; - const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5); - let fanSpeed = simulationNumeric(device?.fan_speed); - if (mode !== 'off') { - fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed; - if (nightActive) { - if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan); - else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan; - } - } - const quiet = mode === 'off' - ? tr('simulation.manual') - : nightActive && app.settings?.night_mode?.force_quiet - ? tr('simulation.quietIfSupported') - : (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual')); - const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true; - const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false - ? tr('simulation.reasonDisabled') - : mode === 'off' - ? tr('simulation.reasonHouseOff') - : current == null || target == null - ? tr('simulation.reasonWaiting') - : demand - ? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)}) - : tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)}); - return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning}; -} - -function simulationRuleAction(rule) { - const bits = []; - if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`); - if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`); - if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`); - if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`); - return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview'); -} - -function simulationGroupsForZone(zoneId) { - return app.groups.filter(group => (group.zone_ids || []).includes(zoneId)); -} - -function renderSimulationScopeControls(plan) { - const scope = $('#simulationScope'); - const target = $('#simulationTarget'); - if (!scope || !target) return; - if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units'; - scope.value = app.simulationScope; - const zones = plan?.zones || []; - const options = app.simulationScope === 'groups' - ? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))] - : [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))]; - if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all'; - target.innerHTML = options.map(option => ``).join(''); - target.value = app.simulationTarget; -} - -function simulationFilteredZones(plan) { - const zones = plan?.zones || []; - if (app.simulationScope === 'groups') { - if (app.simulationTarget === 'all') { - const grouped = new Set(app.groups.flatMap(group => group.zone_ids || [])); - return zones.filter(zone => grouped.has(zone.zone_id)); - } - const group = app.groups.find(item => item.id === app.simulationTarget); - const wanted = new Set(group?.zone_ids || []); - return zones.filter(zone => wanted.has(zone.zone_id)); - } - return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget); -} - -function simulationRuleVisible(rule, zones) { - if (app.simulationTarget === 'all') return true; - const zoneIds = new Set(zones.map(zone => zone.zone_id)); - const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean)); - if (app.simulationScope === 'groups') { - if (rule.action_group_id === app.simulationTarget) return true; - return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id); - } - const directZone = [...zoneIds][0]; - if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true; - return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id); -} - -function simulationLaneContext(planZone) { - if (app.simulationScope !== 'groups') return planZone.device_name || ''; - const groups = simulationGroupsForZone(planZone.zone_id); - return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || ''); -} - -function updateSimulationUrl() { - if (app.currentView !== 'simulation') return; - const params = new URLSearchParams(); - if (app.standaloneSimulation) params.set('standalone', '1'); - if (app.simulationScope !== 'units') params.set('scope', app.simulationScope); - if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget); - const query = params.toString(); - updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true); -} - -function renderSimulationPage() { - const summaryHost = $('#simulationSummary'); - const timelineHost = $('#simulationTimeline'); - const boardHost = $('#simulationFlowBoard'); - const rulesHost = $('#simulationRules'); - if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return; - const plan = app.controlPlan; - if (!plan) { - summaryHost.innerHTML = `
    ${esc(tr('plan.loading'))}
    `; - timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = ''; - return; - } - - renderSimulationScopeControls(plan); - const zones = simulationFilteredZones(plan); - const enabledZones = zones.filter(zone => zone.enabled); - const demandingZones = enabledZones.filter(zone => zone.demand); - const nightOn = !!plan.night_mode_active; - summaryHost.innerHTML = [ - {label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')})}, - {label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`}, - {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, - {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`}, - ].map(card => `
    ${esc(card.label)}${esc(card.value)}${esc(card.note)}
    `).join(''); - - const nodeW = 190, nodeH = 106, rowH = 190, topY = 155; - const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085}; - const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35); - const nodes = []; - const links = []; - const pathBetween = (ax, ay, bx, by, cls='') => { - const bend = Math.max(36, (bx - ax) * .45); - return ``; - }; - const verticalLink = (ax, ay, bx, by, cls='bus') => ``; - const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `
    ${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}

    ${esc(title)}

    ${esc(value)}${meta ? `

    ${esc(meta)}

    ` : ''}
    `; - - const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28; - nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')}),badge:tr('simulation.house')})); - nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''})); - - zones.forEach((planZone, index) => { - const zone = app.zones.find(item => item.id === planZone.zone_id); - const sim = buildZoneSimulation(zone, planZone); - const status = simulationStatusInfo(sim.status); - const y = topY + index * rowH; - const mid = y + nodeH / 2; - const next = (planZone.next_events || [])[0]; - const source = zoneControlSourceLabel(planZone.control_source); - const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined' - ? haSensorLabel(zone?.ha_entity_id || source) - : (planZone.device_name || tr('common.device')); - const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed); - const quietText = sim.quiet; - const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`; - const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort'); - const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents'); - - nodes.push(`
    ${esc(planZone.zone_name)}${esc(simulationLaneContext(planZone))}
    `); - nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'})); - nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)})); - nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge})); - const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : ''; - nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?tr('simulation.running'):tr('simulation.idle')),badgeClass:sim.demand?'active':''})); - nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'})); - - links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link')); - links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link')); - links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link')); - links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link')); - links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus')); - if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus')); - }); - - boardHost.style.width = `${boardW}px`; - boardHost.style.height = `${boardH}px`; - boardHost.innerHTML = `${nodes.join('')}`; - - const timelineItems = []; - if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event})); - zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name}))); - timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at)); - const unique = [], seen = new Set(); - for (const item of timelineItems) { - const key = `${item.scope}|${item.event.at}|${item.event.label}`; - if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break; - } - timelineHost.innerHTML = unique.length ? unique.map(item => { - const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`; - return `
    ${esc(item.scope)}

    ${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

    `; - }).join('') : `
    ${esc(tr('plan.noEvents'))}
    `; - - const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)); - rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
    ${esc(tr('common.automation'))}

    ${esc(rule.name)}

    ${esc(automationTriggerLabel(rule))}

    ${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}

    ${esc(simulationRuleAction(rule))}
    ${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
    `).join('') : `
    ${esc(tr('simulation.noRules'))}
    `; -} - -function scheduleControlPlanLoad() { - clearTimeout(app.controlPlanTimer); - app.controlPlanTimer = setTimeout(loadControlPlan, 180); -} - -function deviceFeaturePanel(device) { - const features = [ - ['light', 'supports_light', tr('devices.light')], - ['xfan', 'supports_xfan', tr('devices.xfan')], - ['health', 'supports_health', tr('devices.health')], - ['air', 'supports_air', tr('devices.air')], - ['sleep', 'supports_sleep', tr('devices.sleep')], - ].filter(([, support]) => device[support] === true); - if (!features.length) return `
    ${esc(tr('devices.features'))}${esc(tr('devices.noExtraFeatures'))}
    `; - return `
    ${esc(tr('devices.features'))}
    ${features.map(([field,,label]) => ``).join('')}
    `; -} - -function deviceZoneDisabled(deviceId) { - return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false); -} - -function deviceCard(device, detailed = false) { - const modes = ['auto','cool','dry','fan','heat']; - const fans = [0,1,3,5]; - const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms)); - const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms'; - const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto'); - const networkInfo = `
    ${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
    `; - const zoneLocked = !detailed && deviceZoneDisabled(device.id); - const locked = zoneLocked ? ' disabled' : ''; - const quickClass = detailed ? '' : ' quick-control-card quick-device-control'; - const quickRow = detailed ? '' : ' quick-control-row'; - return `
    -
    -

    ${esc(device.name)}

    ${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

    - -
    -
    - -
    ${Number(device.target_temperature).toFixed(1)}°C
    - -
    -
    ${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
    - ${zoneLocked ? `
    ${esc(tr('devices.disabledZoneTechnicalOnly'))}
    ` : ''} -
    ${modes.map(mode => ``).join('')}
    -
    ${fans.map(fan => ``).join('')}
    -
    - - ${device.supports_quiet === false ? '' : ``} - ${device.supports_turbo === false ? '' : ``} -
    - ${detailed ? deviceFeaturePanel(device) : ''} - ${detailed ? `` : ''} -
    `; -} - -function renderDevices() { - const empty = `
    ${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
    `; - $('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty; - $('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty; - const dashboardCount = $('#dashboardDeviceCount'); - if (dashboardCount) dashboardCount.textContent = String(app.devices.length); -} - -function zoneStrategyLabel(zone) { - if (zone.sensor_source === 'combined') return tr('zones.combinedSource'); - if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource'); - return tr('zones.greeSource'); -} - -function zoneControlSourceLabel(source) { - return ({ - device: tr('zones.sourceDevice'), - external: tr('zones.sourceExternal'), - combined: tr('zones.sourceCombined'), - device_fallback: tr('zones.sourceFallback'), - device_discrepancy_fallback: tr('zones.sourceDiscrepancy'), - unavailable: tr('zones.sourceUnavailable'), - })[source] || source || tr('zones.sourceUnavailable'); -} - -function zonePresetLabel(preset) { - const key = `preset.${preset || 'comfort'}`; - return tr(key) === key ? (preset || 'comfort') : tr(key); -} - -function zoneControlOwnerLabel(zone) { - const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation')); - const source=zone.control_source || ''; - if(owner==='global_off') return tr('zones.ownerGlobalOff'); - if(owner==='direct_manual') { - if(source==='home_assistant_direct') return tr('zones.ownerHaDirect'); - if(source==='web_direct') return tr('zones.ownerWebDirect'); - return tr('zones.ownerExternal'); - } - if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat'); - return tr('zones.ownerAutomation'); -} - -function zoneControlOwnerMeta(zone) { - const parts=[]; - if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`); - if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`); - if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`); - return parts.join(' · '); -} - -function zoneRuntimeStatusLabel(zone, effectiveMode) { - if (zone.device_manual_override === true) return tr('zones.manualDeviceControl'); - if (!zone.enabled) return tr('common.disabled'); - if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff'); - if ((effectiveMode || 'off') === 'off') return tr('zones.waiting'); - if (zone.current_temperature == null) return tr('zones.noMeasurement'); - return zone.demand ? tr('zones.requesting') : tr('zones.satisfied'); -} - -function zoneCard(zone, detailed = true) { - const device = app.devices.find(d => d.id === zone.device_id); - const state = zone.enabled ? tr('common.active') : tr('common.disabled'); - const sensorDetails = zone.sensor_source === 'device' - ? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}` - : `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`; - const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature; - const target = Number(zone.effective_setpoint ?? zone.setpoint); - const manual = zone.manual_preset || 'auto'; - const displayPreset = zone.manual_preset || zone.active_preset || 'comfort'; - const mode = zone.inherit_house_mode ? 'house' : zone.mode; - const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off'; - const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode); - const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`; - const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null; - const deviceManualOverride = zone.device_manual_override === true; - const localThermostatPower = zone.local_thermostat_power; - const localThermostatResumeAt = zone.local_thermostat_resume_at || null; - const temporarySession = zone.temporary_quick_thermostat || null; - const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null; - const localThermostatOverride = localThermostatPower === true || localThermostatPower === false; - const override = deviceManualOverride - ? (zone.device_manual_override_until - ? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}) - : tr('zones.manualDeviceNoBoundary')) - : temporarySession - ? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'}) - : localThermostatOverride - ? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff') - : (zone.manual_override_until - ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` - : (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl'))); - const manualTakeover = deviceManualOverride - ? `
    ${esc(tr('zones.manualDeviceControl'))}

    ${esc(tr('zones.manualDeviceDescription'))}

    ` - : localThermostatOverride && !temporarySession - ? `
    ${esc(tr('zones.localThermostatControl'))}${localThermostatPower === false && localThermostatResumeAt - ? `

    ${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}

    ` - : `

    ${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}

    `}
    ` - : ''; - const localPowerDisabled = !device || device.enabled === false ? ' disabled' : ''; - const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power); - const localThermostatPowerControl = ``; - const temporaryThermostatControl = temporarySession - ? `` - : ``; - - if (detailed) { - const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name); - const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup'); - const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly'); - return `
    -
    ${esc(tr('zones.configuration'))}

    ${esc(zone.name)}

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

    -
    -
    ${esc(tr('zones.currentStatus'))}
    ${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
    -
    ${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
    -
    -
    -
    ${esc(tr('zones.modePolicy'))}${esc(policy)}
    -
    ${esc(tr('zones.source'))}${esc(zoneStrategyLabel(zone))}
    -
    ${esc(tr('zones.hysteresis'))}${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C
    -
    ${esc(tr('zones.smartFan'))}${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}
    -
    -
    ${esc(sensorDetails)}
    - ${manualTakeover} - -
    `; - } - - return `
    -

    ${esc(zone.name)}

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

    ${localThermostatPowerControl}${temporaryThermostatControl}
    -
    ${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
    ${Number.isFinite(target)?target.toFixed(1):'--'}°C
    ${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
    -
    - ${['auto','comfort','sleep','away'].map(preset=>``).join('')} -
    -
    -
    ${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(override)}
    -
    ${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${esc(zoneControlOwnerMeta(zone))}
    - ${manualTakeover} -
    `; -} - -function renderZones() { - const empty = `
    ${esc(tr('zones.emptyTitle'))}${esc(tr('zones.emptyText'))}
    `; - $('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty; - const dashboard = $('#dashboardZones'); - if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty; - const dashboardCount = $('#dashboardZoneCount'); - if (dashboardCount) dashboardCount.textContent = String(app.zones.length); -} - -function groupZones(group) { - const wanted = new Set(group?.zone_ids || []); - return app.zones.filter(zone => wanted.has(zone.id)); -} - -function groupState(group) { - const zones = groupZones(group); - const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))]; - const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))]; - return { - zones, - mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'), - preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'), - }; -} - -function groupCard(group, detailed = false) { - const state = groupState(group); - const memberNames = state.zones.map(zone => zone.name); - const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers'); - const powerEnabled = group.power_enabled !== false; - const masterOff = app.settings?.house_power_enabled === false; - const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value)); - const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value); - return `
    -
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(memberText)}

    - ${detailed ? `
    ${esc(tr('groups.mode'))}${esc(modeLabelForGroup(state.mode))}
    ${esc(tr('groups.profile'))}${esc(presetLabelForGroup(state.preset))}
    ${esc(tr('groups.members'))}${state.zones.length}
    ` : ''} -
    ${esc(tr('common.power'))}
    -
    ${esc(tr('groups.mode'))}
    ${['house','heat','cool'].map(mode => ``).join('')}
    -
    ${esc(tr('groups.profile'))}
    ${['auto','comfort','sleep','away'].map(preset => ``).join('')}
    - ${masterOff ? `

    ${esc(tr('groups.masterOff'))}

    ` : ''} - ${detailed ? `` : ''} -
    `; -} - -function renderGroups() { - const empty = `
    ${esc(tr('groups.emptyTitle'))}${esc(tr('groups.emptyText'))}
    `; - const dashboard = $('#dashboardGroups'); - if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty; - const dashboardCount = $('#dashboardGroupCount'); - if (dashboardCount) dashboardCount.textContent = String(app.groups.length); - const list = $('#groupList'); - if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty; -} - -async function sendGroupControl(id, patch) { - try { - const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); - if (result.group) { - const index = app.groups.findIndex(group => group.id === result.group.id); - if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group); - } - (result.zones || []).forEach(zone => { - const index = app.zones.findIndex(item => item.id === zone.id); - if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); - }); - (result.devices || []).forEach(updateDevice); - if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; - renderAll(); scheduleControlPlanLoad(); - const failed = Array.isArray(result.failed) ? result.failed.length : 0; - if (failed) toast(tr('groups.partial', {count: failed}), true); - else toast(tr('groups.controlUpdated')); - } catch (error) { toast(error.message, true); } -} - -function renderGroupZoneChoices(selectedIds = null) { - const host = $('#groupZoneChoices'); if (!host) return; - const selected = new Set(selectedIds || []); - host.innerHTML = app.zones.length ? app.zones.map(zone => { - const device = app.devices.find(item => item.id === zone.device_id); - return ``; - }).join('') : `
    ${esc(tr('groups.noZones'))}
    `; -} - -function populateGroup(id) { - const item = app.groups.find(group => group.id === id); if (!item) return; - const form = $('#groupForm'); form.reset(); - form.elements.id.value = item.id; - form.elements.name.value = item.name; - renderGroupZoneChoices(item.zone_ids || []); - openDialog('groupDialog'); -} - -function renderSchedules() { - const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`)); - $('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => { - const zone = app.zones.find(z => z.id === item.zone_id); - const days = item.weekdays.map(day => dayNames[day-1]).join(', '); - return `

    ${esc(item.name)}

    ${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}

    ${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}
    -
    ${esc(tr('common.from'))}${esc(item.start_time)}
    ${esc(tr('common.to'))}${esc(item.end_time)}
    ${esc(tr('schedules.profile'))}${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}
    -
    `; - }).join('') : `
    ${esc(tr('schedules.emptyTitle'))}${esc(tr('schedules.emptyText'))}
    `; -} - -function renderAutomations() { - $('#automationList').innerHTML = app.automations.length ? app.automations.map(item => { - const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null; - const actionDevice = app.devices.find(device => device.id === item.action_device_id); - const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice'); - const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—'); - const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—'; - return `

    ${esc(item.name)}

    ${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}

    ${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}
    -
    ${esc(tr('common.power'))}${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}
    ${esc(tr('common.mode'))}${esc(mode)}
    ${esc(tr('groups.profile'))}${esc(preset)}
    ${esc(tr('automations.last'))}${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}
    -
    `; - }).join('') : `
    ${esc(tr('automations.emptyTitle'))}${esc(tr('automations.emptyText'))}
    `; -} - -function fillSelects() { - const deviceOptions = app.devices.map(d => ``).join(''); - const zoneOptions = app.zones.map(z => ``).join(''); - const groupOptions = app.groups.map(g => ``).join(''); - ['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => { - const select = $(selector); if (!select) return; - const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current; - }); - const automationGroupSelect = $('#automationForm [name=action_group_id]'); - if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; } - const zoneSelect = $('#scheduleForm [name=zone_id]'); - if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; } - const presetZone = $('#schedulePresetZone'); - if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; } - const copyZone=$('#copyZoneSource'); - if(copyZone){ - const current=copyZone.value; - const editingZoneId=$('#zoneForm')?.dataset.editingZoneId || ''; - const sources=app.zones.filter(zone=>zone.id!==editingZoneId); - copyZone.innerHTML=``+sources.map(z=>``).join(''); - copyZone.value=[...copyZone.options].some(option=>option.value===current)?current:''; - } -} - -function renderAccessTokens() { - const list = $('#accessTokenList'); - if (!list) return; - list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => ` -
    -
    ${esc(item.name)}${esc(item.token_prefix)}${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}
    - -
    `).join('') : `
    ${esc(tr('settings.noTokens'))}${esc(tr('settings.noTokensHint'))}
    `; -} - -function knownHaEntities() { - return [...new Set([ - ...Object.keys(app.sensorAliases || {}), - ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), - app.settings?.home_assistant?.default_entity_id, - app.settings?.home_assistant?.outdoor_entity_id, - ...app.historyData.sensors.map(row => row.entity_id), - ].filter(Boolean))].sort(); -} - -function renderSensorAliases() { - const host = $('#sensorAliasList'); if (!host) return; - const entities = knownHaEntities(); - host.innerHTML = entities.length ? entities.map(entity => `
    ${esc(entity)}
    `).join('') : `
    ${esc(tr('settings.noSensorAliases'))}
    `; -} - -function renderLogRetention() { - const select = $('#logRetentionDays'); if (!select || !app.settings) return; - [...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; }); - const days = String(app.settings.event_log_retention_days || 30); - if (![...select.options].some(option => option.value === days)) { - const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option); - } - select.value = days; -} - -function renderSettings() { - if (!app.settings) return; - const form = $('#settingsForm'); - if (!form) return; - form.controller_id.value = app.settings.controller_id || ''; - form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15; - form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5; - form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000'; - form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000; - form.simulator_enabled.checked = !!app.settings.simulator_enabled; - form.history_retention_days.value = app.settings.history_retention_days || 30; - form.event_log_retention_days.value = app.settings.event_log_retention_days || 30; - form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false; - form.suppress_device_beep.checked = !!app.settings.suppress_device_beep; - form.influx_enabled.checked = !!app.settings.influxdb?.enabled; - form.influx_version.value = String(app.settings.influxdb?.version || '2'); - form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30; - form.influx_url.value = app.settings.influxdb?.url || ''; - form.influx_database.value = app.settings.influxdb?.database || 'gree_controller'; - form.influx_username.value = app.settings.influxdb?.username || ''; - form.influx_password.value = ''; - form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); - form.influx_org.value = app.settings.influxdb?.org || ''; - form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller'; - form.influx_token.value = ''; - form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); - form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled; - form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames; - const n=app.settings.notifications||{}; - form.notifications_enabled.checked=!!n.enabled; form.notifications_mode.value=n.mode||'problems'; form.notifications_provider.value=n.provider||'pushover'; - form.pushover_app_token.value=''; form.pushover_user_key.value=''; form.slack_webhook_url.value=''; form.discord_webhook_url.value=''; - form.pushover_app_token.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.applicationTokenPlaceholder'); - form.pushover_user_key.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.userKeyPlaceholder'); - form.slack_webhook_url.placeholder=n.slack_configured?tr('settings.secretSaved'):'https://hooks.slack.com/services/…'; - form.discord_webhook_url.placeholder=n.discord_configured?tr('settings.secretSaved'):'https://discord.com/api/webhooks/…'; - form.notification_cooldown_seconds.value=n.cooldown_seconds||300; form.notification_failure_threshold.value=n.communication_failure_threshold||3; form.notification_target_timeout.value=n.target_timeout_minutes||60; - const alerts=n.alert_types||{}; - form.notification_alert_stale_sensor.checked=alerts.stale_sensor!==false; - form.notification_alert_sensor_errors.checked=alerts.sensor_errors!==false; - form.notification_alert_communication.checked=alerts.communication!==false; - form.notification_alert_target_timeout.checked=alerts.target_timeout!==false; - form.notification_alert_automation.checked=alerts.automation!==false; - form.notification_alert_control_errors.checked=alerts.control_errors!==false; - form.notification_alert_important_events.checked=alerts.important_events!==false; - form.notification_alert_other.checked=alerts.other!==false; - updateNotificationFields(); - updateInfluxFields(); - renderLogRetention(); - renderGreeFrameStats(); - renderSystemInfo(); - renderSimulationModeBanner(); - setSettingsTab(app.settingsTab); - markFormClean(form); -} - -function renderSimulationModeBanner() { - const banner = $('#simulationModeBanner'); - if (!banner) return; - const enabled = !!app.settings?.simulator_enabled; - banner.hidden = !enabled; - document.body.classList.toggle('simulation-mode-enabled', enabled); -} - -function setSettingsTab(tab) { - app.settingsTab = tab === 'gree' ? 'gree' : 'app'; - $$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; }); - $$('[data-settings-tab]').forEach(button => { - const active = button.dataset.settingsTab === app.settingsTab; - button.classList.toggle('active', active); - button.setAttribute('aria-selected', String(active)); - }); -} - -function renderSystemInfo() { - const host = $('#systemInfo'); - if (!host) return; - const devices = app.devices || []; - const online = devices.filter(device => device.online).length; - const simulated = devices.filter(device => device.simulated).length; - const elapsed = Math.max(0, Math.floor((Date.now() - Number(app.systemSnapshotAt || Date.now())) / 1000)); - const uptime = Number(app.system?.uptime_seconds || 0) + elapsed; - const dbPath = String(app.system?.database || ''); - const database = dbPath ? dbPath.split(/[\\/]/).filter(Boolean).pop() : '—'; - const ready = !!app.system?.control_ready; - const runtimeSimulation = !!app.settings?.simulator_enabled; - const items = [ - [tr('settings.version'), app.system?.version || '—', 'version'], - [tr('settings.uptime'), formatDuration(uptime), 'uptime'], - [tr('settings.controlEngine'), ready ? tr('settings.ready') : tr('settings.syncing'), ready ? 'ok' : 'warn'], - [tr('settings.websocket'), tr(`status.${app.connectionStatus}`), app.connectionStatus === 'connected' ? 'ok' : 'warn'], - [tr('settings.runtimeMode'), runtimeSimulation ? tr('settings.runtimeSimulation') : tr('settings.runtimeLive'), runtimeSimulation ? 'warn' : 'ok'], - [tr('settings.apiAuth'), app.system?.auth_required ? tr('settings.enabled') : tr('settings.disabled'), app.system?.auth_required ? 'ok' : 'warn'], - [tr('settings.devicesConfigured'), devices.length, 'devices'], - [tr('settings.devicesOnline'), `${online}/${devices.length}`, online === devices.length && devices.length ? 'ok' : 'neutral'], - [tr('settings.simulatedDevices'), simulated, simulated ? 'warn' : 'neutral'], - [tr('settings.pollCycle'), `${Number(app.settings?.poll_interval_seconds || 0)} s`, 'neutral'], - [tr('settings.zoneCycle'), `${Number(app.settings?.zone_interval_seconds || 0)} s`, 'neutral'], - [tr('settings.homeAssistantStatus'), app.settings?.home_assistant?.url ? tr('settings.configured') : tr('settings.notConfigured'), app.settings?.home_assistant?.url ? 'ok' : 'neutral'], - [tr('settings.influxStatus'), app.settings?.influxdb?.enabled ? `v${app.settings?.influxdb?.version || '2'} · ${tr('settings.enabled')}` : tr('settings.disabled'), app.settings?.influxdb?.enabled ? 'ok' : 'neutral'], - [tr('settings.notificationsStatus'), app.settings?.notifications?.enabled ? tr('settings.enabled') : tr('settings.disabled'), app.settings?.notifications?.enabled ? 'ok' : 'neutral'], - [tr('settings.debugStatus'), app.settings?.debug?.overlay_enabled ? tr('settings.enabled') : tr('settings.disabled'), app.settings?.debug?.overlay_enabled ? 'warn' : 'neutral'], - [tr('settings.receivedFrames'), Number(app.system?.gree_received_frames || 0).toLocaleString(locale()), 'traffic'], - [tr('settings.httpBind'), app.system?.bind || '—', 'mono'], - [tr('settings.greeInterface'), app.system?.gree_interface || 'auto', 'mono'], - [tr('settings.database'), database, 'mono'], - [tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'], - ]; - host.innerHTML = `
    ${esc(tr('settings.systemState'))}

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

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

    ${esc(ready && app.connectionStatus === 'connected' ? tr('settings.healthy') : tr('settings.attention'))}
    ${items.map(([label,value,tone]) => `
    ${esc(label)}${esc(value)}
    `).join('')}
    `; -} - -function renderGreeFrameStats() { - const host = $('#greeFrameStats'); - if (!host) return; - const total = Number(app.system?.gree_received_frames || 0); - const byDevice = app.system?.gree_received_frames_by_device || {}; - const deviceRows = app.devices.filter(device => !device.simulated).map(device => { - const count = Number(byDevice[device.id] || 0); - return `
    ${esc(device.name || device.id)}${count.toLocaleString(locale())}
    `; - }).join(''); - host.innerHTML = `
    ${esc(tr('settings.receivedFramesTotal'))}${total.toLocaleString(locale())}
    ${deviceRows || `
    ${esc(tr('settings.receivedFramesDevices'))}0
    `}`; -} - -function renderNightSettings() { - if (!app.settings) return; - const form = $('#nightModeForm'); - if (!form) return; - form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled; - form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00'; - form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00'; - form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1); - form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false; - form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false; - markFormClean(form); -} - -function renderHomeAssistantSettings() { - if (!app.settings) return; - const form = $('#homeAssistantForm'); - if (!form) return; - form.ha_url.value = app.settings.home_assistant?.url || ''; - form.ha_token.value = ''; - form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'); - form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || ''; - form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || ''; - form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(app.settings.home_assistant?.sensor_stale_after_seconds || 300) / 60))); - form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; - form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; - renderSensorAliases(); - renderAccessTokens(); - markFormClean(form); -} - -function updateInfluxFields() { - const version = $('#settingsForm [name=influx_version]')?.value || '2'; - $$('[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); - renderDebugOverlay(); -} - -function renderDebugOverlay() { - const overlay = $('#debugOverlay'); if (!overlay) return; - const enabled = !!app.settings?.debug?.overlay_enabled; - overlay.hidden = !enabled; - if (!enabled) return; - if (!['all','requests','gree'].includes(app.debugFilter)) app.debugFilter = 'all'; - const status = $('#debugOverlayStatus'); - if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly'); - $$('[data-debug-filter]', overlay).forEach(button => { - const active = button.dataset.debugFilter === app.debugFilter; - button.classList.toggle('active', active); - button.setAttribute('aria-selected', String(active)); - }); - const host = $('#debugOverlayLines'); if (!host) return; - const visible = app.debugLines.filter(line => { - if (app.debugFilter === 'gree') return line.source === 'GREE'; - if (app.debugFilter === 'requests') return line.source === 'HTTP'; - return true; - }).slice(-120); - host.innerHTML = visible.length ? visible.map(line => { - const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`; - const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : 'api'; - return `
    ${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
    `; - }).join('') : `
    ${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : 'debug.empty'))}
    `; - host.scrollTop = host.scrollHeight; -} - -async function loadDebugBacklog() { - if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return; - try { - const data = await api('/api/events?limit=60'); - app.debugLines = (data.events || []).reverse().map(item => ({source:'API', kind:item.kind, message:item.message, timestamp:item.timestamp, data:item.metadata})).slice(-120); - app.debugBacklogLoaded = true; - renderDebugOverlay(); - } catch (_) {} -} - -function formatDuration(seconds) { - const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60); - return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; -} - -setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000); - -const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', groups:'/groups', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'}; -const HISTORY_TABS = ['overview','zones','devices','sensors','custom']; - -function currentHistoryPath() { - const tab = HISTORY_TABS.includes(app.historyTab) ? app.historyTab : 'overview'; - const params = new URLSearchParams(); - const hours = $('#historyHours')?.value || new URLSearchParams(location.search).get('hours') || '24'; - if (hours !== '24') params.set('hours', hours); - if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone); - if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice); - if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor); - if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries)); - const query = params.toString(); - return `/history/${tab}${query ? `?${query}` : ''}`; -} - -function updateBrowserUrl(path, replace=false) { - const target = `${APP_BASE}${path}` || path; - const current = `${location.pathname}${location.search}`; - if (current === target) return; - history[replace ? 'replaceState' : 'pushState']({}, '', target); -} - -function setDashboardTab(tab, {scroll=true}={}) { - const next = ['main','thermostats','manual'].includes(tab) ? tab : 'main'; - app.dashboardTab = next; - $$('[data-dashboard-tab]').forEach(button => { - const active = button.dataset.dashboardTab === next; - button.classList.toggle('active', active); - button.setAttribute('aria-selected', String(active)); - }); - $$('[data-dashboard-panel]').forEach(panel => { - const active = panel.dataset.dashboardPanel === next; - panel.classList.toggle('active', active); - panel.hidden = !active; - }); - if (scroll && app.currentView === 'dashboard') window.scrollTo({top:0, behavior:'smooth'}); -} - -function showView(name, {push=true, scroll=true}={}) { - if (!canLeaveCurrentView(name)) return false; - app.currentView = name; - $$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name)); - $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name)))); - if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard')); - if (scroll) window.scrollTo({top: 0, behavior: 'smooth'}); - if (name === 'dashboard') setDashboardTab(app.dashboardTab, {scroll:false}); - if (name === 'history') { renderHistoryNavigation(); loadHistory(); } - if (name === 'logs') loadLogs(); - return true; -} - -function showHistoryTab(tab, {push=true, load=true}={}) { - app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview'; - renderHistoryNavigation(); - if (push) updateBrowserUrl(currentHistoryPath()); - if (load) loadHistory(); -} - -function applyRouteFromLocation() { - 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); - app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1'; - document.body.classList.toggle('simulation-standalone', app.standaloneSimulation); - if (first === 'simulation') { - app.simulationScope = ['units','groups'].includes(params.get('scope')) ? params.get('scope') : 'units'; - app.simulationTarget = params.get('target') || 'all'; - } - if (first === 'history') { - app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview'; - app.historyZone = params.get('zone') || 'all'; - app.historyDevice = params.get('device') || 'all'; - app.historySensor = params.get('sensor') || 'all'; - const hours = params.get('hours'); - if (hours && ['6','24','168','720','2160','8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; - if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart')); - const previousView = app.currentView; - if (!showView('history', {push:false, scroll:false})) updateBrowserUrl(VIEW_ROUTES[previousView] || '/dashboard', true); - return; - } - const reverse = Object.entries(VIEW_ROUTES).find(([,path]) => path === `/${first}`); - const previousView = app.currentView; - if (!showView(reverse?.[0] || 'dashboard', {push:false, scroll:false})) updateBrowserUrl(previousView === 'history' ? currentHistoryPath() : (VIEW_ROUTES[previousView] || '/dashboard'), true); -} - -async function sendDeviceCommand(id, command) { - try { - const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); - updateDevice(device); renderAll(); - } catch (error) { toast(error.message, true); } -} - -function updateDevice(device) { - const index = app.devices.findIndex(item => item.id === device.id); - if (index >= 0) app.devices[index] = device; else app.devices.push(device); -} - -async function sendZoneLocalPower(id, power) { - try { - const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}}); - const index = app.zones.findIndex(item => item.id === zone.id); - if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); - renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated')); - } catch (error) { toast(error.message, true); } -} - -async function sendZoneControl(id, patch) { - const sequence = (app.zoneControlSeq[id] || 0) + 1; - app.zoneControlSeq[id] = sequence; - try { - const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); - if (app.zoneControlSeq[id] !== sequence) return; - const index = app.zones.findIndex(item => item.id === zone.id); - if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); - renderSummary(); renderZones(); scheduleControlPlanLoad(); - } catch (error) { - if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } - } -} - -function queueZoneTemperature(zone, value) { - const next = Math.round(clamp(value, 8, 30) * 2) / 2; - zone.manual_setpoint = next; - zone.setpoint = next; - zone.effective_setpoint = next; - renderZones(); - clearTimeout(app.zoneTemperatureTimers[zone.id]); - app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160); -} - -function showDiscoveryNames(ids) { - const wanted = new Set(Array.isArray(ids) ? ids : []); - const devices = app.devices.filter(device => wanted.has(device.id)); - if (!devices.length) return; - const list = $('#discoveryNamesList'); - list.innerHTML = devices.map(device => ` - `).join(''); - openDialog('discoveryNamesDialog'); -} - -function populateDeviceRename(id) { - const device = app.devices.find(item => item.id === id); if (!device) return; - const form = $('#renameDeviceForm'); form.reset(); - form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0); - openDialog('renameDeviceDialog'); -} - -async function deleteEntity(type, id, labelKey) { - if (!confirm(tr('confirm.delete', {label: tr(labelKey)}))) return; - try { await api(`/api/${type}/${encodeURIComponent(id)}`, {method:'DELETE'}); await loadBootstrap(); toast(tr('common.removed')); } - catch (error) { toast(error.message, true); } -} - -function openDialog(id) { - fillSelects(); - const dialog = document.getElementById(id); - if (dialog && !dialog.open) { - const form = $('form', dialog); - if (form) { clearFormErrors(form); markFormClean(form); } - dialog.showModal(); - } -} - -function updateZoneSensorFields() { - const form = $('#zoneForm'); - if (!form) return; - const external = form.sensor_source.value !== 'device'; - $('#externalSensorFields').hidden = !external; - form.ha_entity_id.required = external; -} - -function updateSchedulePresetField() { - const form = $('#scheduleForm'); if (!form) return; - $('#scheduleSetpointField').hidden = form.preset.value !== 'custom'; - form.setpoint.required = form.preset.value === 'custom'; -} - -function updateAutomationTargetFields() { - const form = $('#automationForm'); if (!form) return; - const groupTarget = form.action_target_kind.value === 'group'; - $('#automationDeviceTarget').hidden = groupTarget; - $('#automationGroupTarget').hidden = !groupTarget; - $('#automationGroupPreset').hidden = !groupTarget; - $('#automationTargetTemperature').hidden = groupTarget; - $('#automationGroupHint').hidden = !groupTarget; - form.action_device_id.required = !groupTarget; - form.action_group_id.required = groupTarget; - form.action_target_temperature.disabled = groupTarget; - if (groupTarget) form.action_target_temperature.value = ''; - [...form.action_mode.options].forEach(option => { - if (!['dry','fan'].includes(option.value)) return; - option.disabled = groupTarget; - option.hidden = groupTarget; - }); - if (groupTarget && ['dry','fan'].includes(form.action_mode.value)) form.action_mode.value = ''; -} - -function populateZone(id) { - const item = app.zones.find(v => v.id === id); if (!item) return; - const form = $('#zoneForm'); - form.reset(); - form.dataset.editingZoneId = item.id; - form.elements.id.value = item.id; - fillSelects(); - Object.entries(item).forEach(([key,value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; }); - form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool'); - if (Number(item.profile_version || 0) === 0) { - if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21); - else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23); - } - form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100); - form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3); - form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120); - form.standby_offset_c.value = Number(item.standby_offset_c ?? 2); - form.smart_fan.checked = item.smart_fan !== false; - form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog'); -} - -const dateTimeLocalValue = value => { - const date = value instanceof Date ? value : new Date(value); - if (!Number.isFinite(date.getTime())) return ''; - const pad = number => String(number).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -}; - -function updateTemporaryThermostatFields() { - const form = $('#temporaryThermostatForm'); if (!form) return; - const startKind = form.start_kind.value; - const kind = form.finish_kind.value; - const startDelay = $('#temporaryStartDelayFields'); - const startAt = $('#temporaryStartAtFields'); - const duration = $('#temporaryDurationFields'); - const until = $('#temporaryUntilFields'); - const temperature = $('#temporaryTemperatureFields'); - const schedule = $('#temporaryScheduleFields'); - startDelay.hidden = startKind !== 'delay'; - startAt.hidden = startKind !== 'at'; - duration.hidden = kind !== 'duration'; - until.hidden = kind !== 'until'; - temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind); - schedule.hidden = kind !== 'schedule_boundary'; - $('#temporaryHoldField').hidden = kind !== 'temperature_stable'; - form.start_delay_minutes.required = startKind === 'delay'; - form.start_at.required = startKind === 'at'; - form.duration_minutes.required = kind === 'duration'; - form.until.required = kind === 'until'; - form.hold_minutes.required = kind === 'temperature_stable'; -} - -function populateTemporaryThermostat(id) { - const zone = app.zones.find(item => item.id === id); if (!zone) return; - const form = $('#temporaryThermostatForm'); - form.reset(); - form.elements.zone_id.value = zone.id; - form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1); - form.start_kind.value = 'now'; - form.start_delay_minutes.value = '30'; - form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); - form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)); - const session = zone.temporary_quick_thermostat; - const activeSession = !!session?.activated_at; - form.dataset.activeSession = activeSession ? 'true' : 'false'; - if (session) { - form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now'); - if (form.start_kind.value === 'delay' && session.started_at) { - form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000)); - } - if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at); - form.finish_kind.value = session.finish_kind || 'duration'; - if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1); - if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator; - form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1); - if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60)); - if (session.finish_kind === 'duration') { - if (session.duration_seconds) { - form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60)); - } else if (session.expires_at) { - const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); - form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000)); - } - } - if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at); - if (session.safety_duration_seconds) { - form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60)); - } else if (session.safety_expires_at) { - const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); - form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000)); - } else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) { - form.max_duration_minutes.value = ''; - } - } - - // Once ownership has started, the historical start is informational. Editing changes only - // target/finish rules; rescheduling requires stopping the session and creating a new one. - form.start_kind.disabled = activeSession; - form.start_delay_minutes.disabled = activeSession; - form.start_at.disabled = activeSession; - $$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; }); - - updateTemporaryThermostatFields(); - const active = $('#temporaryThermostatActive'); - active.hidden = !session; - $('#temporaryThermostatStop').dataset.id = zone.id; - $('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart'); - updateTemporaryThermostatCountdowns(); - openDialog('temporaryThermostatDialog'); - updateTemporaryThermostatCountdowns(); -} - -function populateSchedule(id) { - const item = app.schedules.find(v => v.id === id); if (!item) return; - const form = $('#scheduleForm'); form.reset(); fillSelects(); - ['id','name','zone_id','start_time','end_time','setpoint','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); - form.enabled.checked = item.enabled; - $$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value))); - updateSchedulePresetField(); openDialog('scheduleDialog'); -} - -function populateAutomation(id) { - const item = app.automations.find(v => v.id === id); if (!item) return; - const form = $('#automationForm'); form.reset(); fillSelects(); - ['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); - const groupTarget = !!item.action_group_id; - form.action_target_kind.value = groupTarget ? 'group' : 'device'; - if (groupTarget) form.action_group_id.value = item.action_group_id; - form.action_power.value = item.action.power == null ? '' : String(item.action.power); - form.action_mode.value = item.action.mode || ''; - form.action_preset.value = item.action_preset || ''; - form.action_target_temperature.value = item.action.target_temperature ?? ''; - form.enabled.checked = item.enabled; - updateAutomationTargetFields(); - openDialog('automationDialog'); -} - -function encodeChartSpec(series) { - try { - const raw = JSON.stringify(series || []); - return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); - } catch (_) { return ''; } -} - -function decodeChartSpec(encoded) { - try { - const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '='); - const value = JSON.parse(atob(padded)); - return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : []; - } catch (_) { return []; } -} - -function historyEntityOptions() { - const zones = app.zones.map(zone => ``).join(''); - const devices = app.devices.map(device => ``).join(''); - const entities = [...new Set([ - ...app.historyData.sensors.map(row => row.entity_id), - ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), - app.settings?.home_assistant?.outdoor_entity_id, - ].filter(Boolean))].sort(); - const sensors = entities.map(entity => ``).join(''); - return {zones, devices, sensors, entities}; -} - -function renderHistoryNavigation() { - $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); - const host = $('#historyContextControls'); if (!host) return; - const options = historyEntityOptions(); - if (app.historyTab === 'zones') { - host.innerHTML = ``; - const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone; - } else if (app.historyTab === 'devices') { - host.innerHTML = ``; - const select=$('#historyDeviceSelect'); if ([...select.options].some(option=>option.value===app.historyDevice)) select.value=app.historyDevice; - } else if (app.historyTab === 'sensors') { - host.innerHTML = ``; - const select=$('#historySensorSelect'); if ([...select.options].some(option=>option.value===app.historySensor)) select.value=app.historySensor; - } else if (app.historyTab === 'custom') { - host.innerHTML = `${esc(tr('history.customHint'))}`; - } else { - host.innerHTML = `${esc(tr('history.overviewHint'))}`; - } -} - -async function loadHistory() { - if (app.historyLoading) return; - app.historyLoading = true; - const hours = $('#historyHours')?.value || '24'; - try { - const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`); - app.historyData = {zones:data.zones || [], devices:data.devices || [], sensors:data.sensors || []}; - app.historyCounts = data.counts || {}; - renderHistoryNavigation(); - renderHistoryPage(); - if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true); - } catch (error) { - toast(error.message, true); - renderHistoryPage(); - } finally { app.historyLoading = false; } -} - -const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue']; - -function cssColor(name, fallback) { - const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - return value || fallback; -} - -function timeLabel(ts, hours) { - const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'}; - return new Date(ts).toLocaleString(locale(), options); -} - -function prepareCanvas(canvas, height) { - const rect = canvas.getBoundingClientRect(); - const wrapWidth = Math.floor(canvas.parentElement?.clientWidth || rect.width || 720); - const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4); - const width = Math.max(720, Math.floor(Math.max(720, wrapWidth) * zoom)); - const dpr = window.devicePixelRatio || 1; - canvas.width = width * dpr; canvas.height = height * dpr; - canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; - const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); - ctx.clearRect(0,0,width,height); - return {ctx,width,height}; -} - -function drawEmptyChart(canvas, height=340) { - if (!canvas) return; - const {ctx,width} = prepareCanvas(canvas,height); - ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center'; - ctx.fillText(tr('history.noData'), width/2, height/2); - const wrap=canvas.parentElement; - const tooltip=wrap?.querySelector('.chart-tooltip'); - const line=wrap?.querySelector('.chart-hover-line'); - if(tooltip) tooltip.hidden=true; - if(line) line.hidden=true; - canvas.onpointermove=null; canvas.onpointerleave=null; canvas.onpointerdown=null; canvas.onkeydown=null; -} - -function chartPreciseTime(timestamp) { - return new Intl.DateTimeFormat(locale(), { - year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' - }).format(new Date(timestamp)); -} - -function nearestPoint(points, targetTs) { - if (!points.length) return null; - let lo=0, hi=points.length-1; - while(lo0?points[lo-1]:null; - if(!before) return after; - return Math.abs(before.ts-targetTs) <= Math.abs(after.ts-targetTs) ? before : after; -} - -function formatChartTooltipValue(item, point, binaryLabels) { - if (!point || !Number.isFinite(point.value)) return '—'; - if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row)); - const formatted = Number(point.value).toLocaleString(locale(), {minimumFractionDigits:1, maximumFractionDigits:2}); - return binaryLabels ? formatted : `${formatted} °C`; -} - -function bindChartTooltip(canvas, series, sortedRows, geometry) { - const wrap=canvas?.parentElement; - const tooltip=wrap?.querySelector('.chart-tooltip'); - const line=wrap?.querySelector('.chart-hover-line'); - if(!canvas || !wrap || !tooltip || !line) return; - - const {pad,width,height,firstTs,lastTs,binaryLabels}=geometry; - const plotWidth=width-pad.left-pad.right; - const timestamps=[...new Set(sortedRows.map(row=>new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a,b)=>a-b); - const timestampPoints=timestamps.map(ts=>({ts,value:ts})); - const pointsBySeries=series.map(item=>({ - item, - points:sortedRows.map(row=>({row,ts:new Date(row.timestamp).getTime(),value:item.value(row)})) - .filter(point=>Number.isFinite(point.ts)&&Number.isFinite(point.value)) - .sort((a,b)=>a.ts-b.ts) - })); - - const hide=()=>{ tooltip.hidden=true; line.hidden=true; }; - const showAtTimestamp=(targetTs)=>{ - if(!timestamps.length) return hide(); - const snapped=nearestPoint(timestampPoints,targetTs)?.ts; - if(!Number.isFinite(snapped)) return hide(); - const px=pad.left + (snapped-firstTs)/Math.max(1,lastTs-firstTs)*plotWidth; - const values=pointsBySeries.map(({item,points})=>({item,point:nearestPoint(points,snapped)})).filter(entry=>entry.point); - if(!values.length) return hide(); - - $$('.chart-tooltip').forEach(node=>{ if(node!==tooltip) node.hidden=true; }); - $$('.chart-hover-line').forEach(node=>{ if(node!==line) node.hidden=true; }); - tooltip.innerHTML=`${esc(chartPreciseTime(snapped))}
    ${values.map(({item,point})=>{ - const sampleTime=Math.abs(point.ts-snapped)>1000?`${esc(chartPreciseTime(point.ts))}`:''; - return `
    ${esc(item.label)}${esc(formatChartTooltipValue(item,point,binaryLabels))}${sampleTime}
    `; - }).join('')}
    `; - tooltip.hidden=false; - line.hidden=false; - line.style.left=`${px}px`; - line.style.top=`${pad.top}px`; - line.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`; - - const minLeft=wrap.scrollLeft+8; - const maxLeft=wrap.scrollLeft+wrap.clientWidth-tooltip.offsetWidth-8; - let tooltipLeft=px+12; - if(tooltipLeft+tooltip.offsetWidth>wrap.scrollLeft+wrap.clientWidth-8) tooltipLeft=px-tooltip.offsetWidth-12; - tooltip.style.left=`${Math.max(minLeft,Math.min(tooltipLeft,Math.max(minLeft,maxLeft)))}px`; - tooltip.style.top=`${pad.top+8}px`; - canvas.dataset.chartHoverTs=String(snapped); - }; - - const showFromClientX=(clientX)=>{ - const rect=canvas.getBoundingClientRect(); - const localX=clamp(clientX-rect.left,pad.left,width-pad.right); - const targetTs=firstTs + ((localX-pad.left)/Math.max(1,plotWidth))*(lastTs-firstTs); - showAtTimestamp(targetTs); - }; - - canvas.onpointermove=event=>showFromClientX(event.clientX); - canvas.onpointerdown=event=>showFromClientX(event.clientX); - canvas.onpointerleave=event=>{ if(event.pointerType!=='touch') hide(); }; - canvas.onkeydown=event=>{ - if(!['ArrowLeft','ArrowRight'].includes(event.key) || !timestamps.length) return; - event.preventDefault(); - const current=Number(canvas.dataset.chartHoverTs); - let index=Number.isFinite(current)?timestamps.findIndex(ts=>ts===current):-1; - if(index<0) index=event.key==='ArrowRight'?0:timestamps.length-1; - else index=clamp(index+(event.key==='ArrowRight'?1:-1),0,timestamps.length-1); - showAtTimestamp(timestamps[index]); - }; -} - -function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) { - if (!canvas || !rows.length || !series.length) return drawEmptyChart(canvas, height); - const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp)); - const {ctx,width} = prepareCanvas(canvas,height); - const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'); - const pad={left:54,right:20,top:20,bottom:42}; - const allValues=[]; - series.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); })); - if (!allValues.length) return drawEmptyChart(canvas,height); - let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue; - let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue; - if (max-min < 2) { min-=1; max+=1; } - const firstTs = new Date(sortedRows[0].timestamp).getTime(); - const lastTs = new Date(sortedRows[sortedRows.length-1].timestamp).getTime(); - const span = Math.max(1,lastTs-firstTs); - const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right); - const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom); - ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1; - for(let i=0;i<=5;i++){ - const value=min+(max-min)*i/5, py=y(value); - ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke(); - ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3); - } - const ticks=5; - for(let i=0;i<=ticks;i++){ - const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]); - ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14); - } - series.forEach(item => { - ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]); - let started=false, prev=null; - sortedRows.forEach(row => { - const value=item.value(row); if(!Number.isFinite(value)) return; - const px=x(row),py=y(value); - if(!started||prev===null){ctx.moveTo(px,py);started=true;} - else if(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py); - prev=value; - }); - ctx.stroke(); ctx.setLineDash([]); - }); - bindChartTooltip(canvas,series,sortedRows,{pad,width,height,firstTs,lastTs,binaryLabels}); -} - -function renderLegend(host, series) { - if (!host) return; - host.innerHTML=series.map(item=>`${esc(item.label)}`).join(''); -} - -function historyChartMarkup(id, title, hint, compact=false) { - const zoom = clamp(Number(app.chartZooms[id] || 1), 1, 4); - const percent = Math.round(zoom * 100); - return `

    ${esc(title)}

    ${esc(hint || '')}

    ${esc(tr('history.hoverHint'))}
    `; -} - -function historySeriesColor(index) { - return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`); -} - -function renderHistorySummary() { - const host=$('#historySummary'); if(!host) return; - const deviceRows=app.historyData.devices, zoneRows=app.historyData.zones, sensorRows=app.historyData.sensors; - const cards=[ - [tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')], - [tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')], - [tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')], - ]; - host.innerHTML=cards.map(([label,value,detail])=>`
    ${esc(label)}${esc(value)}${esc(detail)}
    `).join(''); -} - -function renderOverviewHistory() { - const host=$('#historyCharts'); - host.innerHTML = historyChartMarkup('overviewIndoorChart',tr('history.allGreeIndoor'),tr('history.allGreeIndoorHint')) - + historyChartMarkup('overviewOutdoorChart',tr('history.allOutdoor'),tr('history.allOutdoorHint')) - + historyChartMarkup('overviewZonesChart',tr('history.allZoneControl'),tr('history.allZoneControlHint')); - - const deviceRows=app.historyData.devices; - const indoorSeries=app.devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.indoor_temperature):NaN})); - drawLineChart($('#overviewIndoorChart'),indoorSeries,deviceRows,{height:360}); renderLegend($('#overviewIndoorChartLegend'),indoorSeries); - - const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN})); - const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))]; - const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(entity)}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN})); - const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')]; - const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries]; - drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries); - - const zoneRows=app.historyData.zones; - const zoneSeries=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN})); - drawLineChart($('#overviewZonesChart'),zoneSeries,zoneRows,{height:340}); renderLegend($('#overviewZonesChartLegend'),zoneSeries); -} - -function renderZoneHistory() { - const selected=app.historyZone; - const rows=selected==='all'?app.historyData.zones:app.historyData.zones.filter(row=>row.zone_id===selected); - const host=$('#historyCharts'); - host.innerHTML=historyChartMarkup('zoneTemperatureChart',tr('history.temperatureOverview'),tr('history.temperatureOverviewHint'))+historyChartMarkup('zoneOperationChart',tr('history.operationOverview'),tr('history.operationOverviewHint'),true); - if(selected==='all'){ - const series=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN})); - drawLineChart($('#zoneTemperatureChart'),series,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),series); - const targets=app.zones.map((zone,index)=>({label:`${zone.name} · ${tr('history.targetShort')}`,color:historySeriesColor(index),dash:[5,4],value:row=>row.zone_id===zone.id?historyNumber(row.target_temperature):NaN})); - drawLineChart($('#zoneOperationChart'),targets,rows,{height:260}); renderLegend($('#zoneOperationChartLegend'),targets); - return; - } - const temperatureSeries=[ - {label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:row=>historyNumber(row.gree_temperature)}, - {label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:row=>historyNumber(row.external_temperature)}, - {label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:row=>historyNumber(row.control_temperature)}, - {label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:row=>historyNumber(row.target_temperature)}, - {label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:row=>historyNumber(row.device_setpoint)}, - {label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:row=>historyNumber(row.outdoor_temperature)}, - ]; - drawLineChart($('#zoneTemperatureChart'),temperatureSeries,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),temperatureSeries); - const operationSeries=[ - {label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed),tooltipValue:value=>fanLabel(Math.round(value))}, - {label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5,tooltipValue:(_,row)=>row.demand?tr('common.active'):tr('common.disabled')}, - {label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5,tooltipValue:(_,row)=>row.power?tr('common.on'):tr('common.off')}, - ]; - drawLineChart($('#zoneOperationChart'),operationSeries,rows,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#zoneOperationChartLegend'),operationSeries); -} - -function renderDeviceHistory() { - const selected=app.historyDevice; - const rows=selected==='all'?app.historyData.devices:app.historyData.devices.filter(row=>row.device_id===selected); - const host=$('#historyCharts'); - host.innerHTML=historyChartMarkup('deviceIndoorChart',tr('history.deviceIndoor'),tr('history.deviceIndoorHint'))+historyChartMarkup('deviceOutdoorChart',tr('history.deviceOutdoor'),tr('history.deviceOutdoorHint'))+historyChartMarkup('deviceTargetChart',tr('history.deviceTargets'),tr('history.deviceTargetsHint'),true); - const devices=selected==='all'?app.devices:app.devices.filter(device=>device.id===selected); - const indoor=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.indoor_temperature):NaN})); - const outdoor=devices.filter(device=>rows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN})); - const targets=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),dash:[6,4],value:row=>row.device_id===device.id?historyNumber(row.target_temperature):NaN})); - drawLineChart($('#deviceIndoorChart'),indoor,rows,{height:350}); renderLegend($('#deviceIndoorChartLegend'),indoor); - drawLineChart($('#deviceOutdoorChart'),outdoor,rows,{height:310}); renderLegend($('#deviceOutdoorChartLegend'),outdoor); - drawLineChart($('#deviceTargetChart'),targets,rows,{height:260}); renderLegend($('#deviceTargetChartLegend'),targets); -} - -function renderSensorHistory() { - const selected=app.historySensor; - const rows=selected==='all'?app.historyData.sensors:app.historyData.sensors.filter(row=>row.entity_id===selected); - const entities=[...new Set(rows.map(row=>row.entity_id))]; - const host=$('#historyCharts'); - host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint')); - const series=entities.map((entity,index)=>({label:haSensorLabel(entity),color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN})); - drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series); -} - -function customSeriesOptions() { - const items=[]; - app.devices.forEach(device=>{ - items.push([`device|${device.id}|indoor`,`${device.name} · ${tr('history.indoorTemperature')}`]); - items.push([`device|${device.id}|outdoor`,`${device.name} · ${tr('history.greeOutdoor')}`]); - items.push([`device|${device.id}|target`,`${device.name} · ${tr('history.deviceTarget')}`]); - }); - app.zones.forEach(zone=>{ - items.push([`zone|${zone.id}|control`,`${zone.name} · ${tr('history.controlTemperature')}`]); - items.push([`zone|${zone.id}|gree`,`${zone.name} · ${tr('history.greeSensor')}`]); - items.push([`zone|${zone.id}|external`,`${zone.name} · ${tr('history.roomSensor')}`]); - items.push([`zone|${zone.id}|target`,`${zone.name} · ${tr('history.comfortTarget')}`]); - items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]); - items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]); - }); - [...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`])); - return items; -} - -function customSeriesDefinition(key,index=0) { - const [kind,id,field]=String(key).split('|'); - const color=historySeriesColor(index); - if(kind==='device'){ - const device=app.devices.find(item=>item.id===id); if(!device) return null; - const labels={indoor:tr('history.indoorTemperature'),outdoor:tr('history.greeOutdoor'),target:tr('history.deviceTarget')}; - const fields={indoor:'indoor_temperature',outdoor:'outdoor_temperature',target:'target_temperature'}; - return {key,label:`${device.name} · ${labels[field]||field}`,color,dash:field==='target'?[6,4]:[],value:row=>!row.zone_id&&!row.entity_id&&row.device_id===id?historyNumber(row[fields[field]]):NaN}; - } - if(kind==='zone'){ - const zone=app.zones.find(item=>item.id===id); if(!zone) return null; - const fields={control:['control_temperature',tr('history.controlTemperature')],gree:['gree_temperature',tr('history.greeSensor')],external:['external_temperature',tr('history.roomSensor')],target:['target_temperature',tr('history.comfortTarget')],device_target:['device_setpoint',tr('history.deviceSetpoint')],outdoor:['outdoor_temperature',tr('history.outdoorTemperature')]}; - const info=fields[field]; if(!info) return null; - return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN}; - } - if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN}; - return null; -} - -function persistSavedCharts() { - localStorage.setItem('gree_controller_saved_charts',JSON.stringify(app.savedCharts.slice(0,30))); -} - -function renderCustomBuilder() { - const host=$('#historyCustomBuilder'); if(!host) return; - if(app.historyTab!=='custom'){host.innerHTML='';host.classList.remove('active');return;} - host.classList.add('active'); - const options=customSeriesOptions(); - const selected=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); - host.innerHTML=`

    ${esc(tr('history.customTitle'))}

    ${esc(tr('history.customDescription'))}

    -
    -
    ${selected.length?selected.map((item,index)=>`${esc(item.label)}`).join(''):`${esc(tr('history.noCustomSeries'))}`}
    -
    -
    ${app.savedCharts.length?app.savedCharts.map(item=>`
    `).join(''):`${esc(tr('history.noSavedCharts'))}`}
    -
    `; -} - -function renderCustomHistory() { - renderCustomBuilder(); - const host=$('#historyCharts'); - host.innerHTML=historyChartMarkup('customHistoryChart',tr('history.customChart'),tr('history.customChartHint')); - const series=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); - const rows=[...app.historyData.devices,...app.historyData.zones,...app.historyData.sensors]; - drawLineChart($('#customHistoryChart'),series,rows,{height:390}); renderLegend($('#customHistoryChartLegend'),series); -} - -function renderHistoryPage() { - renderHistorySummary(); - renderCustomBuilder(); - if(app.historyTab==='overview') renderOverviewHistory(); - else if(app.historyTab==='zones') renderZoneHistory(); - else if(app.historyTab==='devices') renderDeviceHistory(); - else if(app.historyTab==='sensors') renderSensorHistory(); - else renderCustomHistory(); -} - -function drawCurrentChartIfVisible() { - if (app.currentView === 'history') renderHistoryPage(); -} - -async function handleHistoryAction(button) { - const action=button.dataset.historyAction; - if(action==='add-series'){ - const value=$('#customSeriesSelect')?.value; - if(value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value); - renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; - } - if(action==='remove-series'){ - app.customChartSeries.splice(Number(button.dataset.index),1); - renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; - } - if(action==='save-chart'){ - if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); - const name=$('#customChartName')?.value.trim() || tr('history.customChart'); - const item={id:`chart-${Date.now()}`,name,series:[...app.customChartSeries],hours:$('#historyHours')?.value||'24'}; - app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return; - } - if(action==='load-chart'){ - const item=app.savedCharts.find(entry=>entry.id===button.dataset.id); if(!item) return; - app.customChartSeries=[...item.series]; if($('#historyHours')&&item.hours) $('#historyHours').value=String(item.hours); - renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return; - } - if(action==='delete-chart'){ - app.savedCharts=app.savedCharts.filter(entry=>entry.id!==button.dataset.id); persistSavedCharts(); renderCustomHistory(); return; - } - if(action==='copy-chart-link'){ - if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); - const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${APP_BASE}${path}`; - try { await navigator.clipboard.writeText(link); } catch (_) { const area=document.createElement('textarea'); area.value=link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); } - toast(tr('history.linkCopied')); return; - } -} - -async function loadLogs() { - renderLogRetention(); - try { - const data = await api('/api/events?limit=150'); - const level=$('#logLevelFilter')?.value||'all', category=$('#logCategoryFilter')?.value||'all'; - const logs = (data.events || []).filter(item => (level==='all'||item.level===level) && (category==='all'||logCategory(item.kind)===category)); - $('#logList').innerHTML = logs.length - ? 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); } -} - -function connectWebSocket() { - if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return; - 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}${withBase('/ws')}${query}`); app.ws = ws; - ws.onopen = () => updateConnectionIndicator('connected'); - ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; - ws.onerror = () => updateConnectionIndicator('connectionError'); - ws.onmessage = event => { - try { - const message = JSON.parse(event.data); - if (message.event === 'bootstrap') { - const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.systemSnapshotAt=Date.now(); app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; - } - const data = message.data || {}; - if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } - else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } - else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } - else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } - else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } - else if (message.event === 'settings.updated') { - const settingsDirty=isFormDirty($('#settingsForm')), nightDirty=isFormDirty($('#nightModeForm')), haDirty=isFormDirty($('#homeAssistantForm')); - app.settings=data; - if (!haDirty) app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; - if (!settingsDirty) renderSettings(); - if (!nightDirty) renderNightSettings(); - if (!haDirty) renderHomeAssistantSettings(); - renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); - } - else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } - else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); } - else if (message.event === 'gree.frame_received') { - app.system = app.system || {}; - app.system.gree_received_frames = Number(data.total || 0); - app.system.gree_received_frames_by_device = {...(app.system.gree_received_frames_by_device || {})}; - if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0); - renderGreeFrameStats(); - renderSystemInfo(); - } - else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } - else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } - else if (message.event === 'log.created') { if(app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if(app.currentView === 'logs') loadLogs(); } - else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad(); - } catch (_) {} - }; -} - -document.addEventListener('click', async event => { - const button = event.target.closest('button'); if (!button) return; - if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; } - if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; } - if (button.dataset.dashboardTab) { setDashboardTab(button.dataset.dashboardTab); return; } - if (button.dataset.nav) { - if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav); - return; - } - if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; } - if (button.dataset.open) { - const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); - if (form) form.reset(); - if (button.dataset.open === 'zoneDialog') { - if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; } - updateZoneSensorFields(); - } - if (button.dataset.open === 'groupDialog') { - if (form?.elements.id) form.elements.id.value = ''; - renderGroupZoneChoices([]); - } - if (button.dataset.open === 'automationDialog') updateAutomationTargetFields(); - if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); - openDialog(button.dataset.open); - return; - } - if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; } - if (button.dataset.chartZoom) { - const id = button.dataset.chartId; - const canvas = id ? document.getElementById(id) : null; - const wrap = canvas?.parentElement; - const oldWidth = canvas?.getBoundingClientRect().width || 1; - const centerRatio = wrap ? (wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth : .5; - const current = clamp(Number(app.chartZooms[id] || 1), 1, 4); - const next = button.dataset.chartZoom === 'in' ? Math.min(4, current + .5) : button.dataset.chartZoom === 'out' ? Math.max(1, current - .5) : 1; - app.chartZooms[id] = next; - renderHistoryPage(); - requestAnimationFrame(() => { - const nextCanvas = document.getElementById(id); - const nextWrap = nextCanvas?.parentElement; - if (!nextCanvas || !nextWrap) return; - const nextWidth = nextCanvas.getBoundingClientRect().width || 1; - nextWrap.scrollLeft = Math.max(0, centerRatio * nextWidth - nextWrap.clientWidth / 2); - }); - return; - } - if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; } - if (button.dataset.historyAction) { await handleHistoryAction(button); return; } - if (button.dataset.scheduleTemplate) { - const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true); - if (!confirm(tr('schedules.replaceConfirm'))) return; - try { - button.disabled = true; - await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}}); - await loadBootstrap(); toast(tr('schedules.templateApplied')); - } catch (error) { toast(error.message, true); } finally { button.disabled = false; } - return; - } - const action = button.dataset.action; if (!action) return; - const device = app.devices.find(v => v.id === button.dataset.device); - if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) { - return toast(tr('devices.disabledZoneTechnicalOnly'), true); - } - if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power}); - if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)}); - if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true}); - if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)}); - if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]}); - if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } - if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } - if (action === 'rename-device' && device) return populateDeviceRename(device.id); - if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); - if (action === 'house-mode') { - try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); } - catch(error){ toast(error.message,true); } return; - } - if (action === 'house-power') { - const power = button.dataset.value === 'true'; - if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return; - try { - button.disabled = true; - const result = await api('/api/house/power',{method:'POST',body:{power}}); - app.settings = result.settings || app.settings; - app.devices = result.devices || app.devices; - app.groups = result.groups || app.groups; - renderAll(); scheduleControlPlanLoad(); - const failed = Array.isArray(result.failed) ? result.failed.length : 0; - if (failed) toast(tr('house.powerPartial', {count: failed}), true); - else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone')); - } catch(error) { toast(error.message, true); } - finally { button.disabled = false; } - return; - } - if (action === 'house-preset') { - try { - const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); - app.settings=result.settings||app.settings; app.zones=result.zones||app.zones; app.devices=result.devices||app.devices; - renderAll(); scheduleControlPlanLoad(); - const failed=Array.isArray(result.failed)?result.failed.length:0; - if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated')); - } - catch(error){ toast(error.message,true); } return; - } - if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); - if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value}); - if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); - if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true'); - if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id); - if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; } - if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value}); - if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value}); - if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'}); - if (action === 'zone-resume-automation') { - const patch = button.dataset.resume === 'local' - ? {clear_local_thermostat_override:true} - : {clear_device_manual_override:true}; - return sendZoneControl(button.dataset.id, patch); - } - if (action === 'zone-go-control') { - showView('dashboard', {scroll:false}); - setDashboardTab('thermostats', {scroll:false}); - const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id); - requestAnimationFrame(() => target?.scrollIntoView({behavior:'smooth', block:'center'})); - return; - } - if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; } - if (action === 'edit-group') return populateGroup(button.dataset.id); - if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group'); - if (action === 'edit-zone') return populateZone(button.dataset.id); - if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone'); - if (action === 'edit-schedule') return populateSchedule(button.dataset.id); - if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule'); - if (action === 'edit-automation') return populateAutomation(button.dataset.id); - if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation'); - if (action === 'revoke-access-token') { - if (!confirm(tr('confirm.revokeToken'))) return; - try { - await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'}); - app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id); - renderAccessTokens(); - toast(tr('toast.tokenRevoked')); - } catch (error) { toast(error.message, true); } - return; - } -}); - -$('#refreshButton').addEventListener('click', () => { - const form = activeDirtySettingsForm(); - if (form && !confirmDiscardForm(form)) return; - loadBootstrap(true); -}); -$('#connectionRetry')?.addEventListener('click', async event => { - const button = event.currentTarget; - button.disabled = true; - try { - if (app.ws) { try { app.ws.close(); } catch (_) {} app.ws = null; } - await loadBootstrap(false); - connectWebSocket(); - } finally { button.disabled = false; } -}); -$('#discoverButton').addEventListener('click', () => { - const form=$('#discoverForm'); form.reset(); - form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000))); - openDialog('discoverDialog'); -}); -$('#historyRefresh').addEventListener('click', loadHistory); -$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); }); -$('#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); -$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields); -$('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); }); -$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); }); -$('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); }); -$('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } }); -document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); }); - -$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60),alert_types:{stale_sensor:f.notification_alert_stale_sensor.checked,sensor_errors:f.notification_alert_sensor_errors.checked,communication:f.notification_alert_communication.checked,target_timeout:f.notification_alert_target_timeout.checked,automation:f.notification_alert_automation.checked,control_errors:f.notification_alert_control_errors.checked,important_events:f.notification_alert_important_events.checked,other:f.notification_alert_other.checked}}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast(tr('notifications.testSent'));}catch(e){toast(e.message,true);} }); - -$('#copyZoneSettings')?.addEventListener('click',()=>{ - const f=$('#zoneForm'); - const source=app.zones.find(zone=>zone.id===$('#copyZoneSource')?.value); - if(!source)return; - const targetId=f.dataset.editingZoneId || ''; - if(targetId && source.id===targetId){ toast(tr('zones.copyDifferent'),true); return; } - - // Identity and sensor assignment belong to the destination profile and must never - // be changed by copying thermostat tuning from another profile. - const protectedValues={ - id:f.elements.id.value, - name:f.elements.name.value, - device_id:f.elements.device_id.value, - sensor_source:f.elements.sensor_source.value, - ha_entity_id:f.elements.ha_entity_id.value, - enabled:f.elements.enabled.checked, - }; - const copyFields=['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','sensor_stale_after_seconds']; - copyFields.forEach(name=>{ - const input=f.elements[name]; - if(!input)return; - const value=name==='external_sensor_weight_percent'?(source.external_sensor_weight*100):source[name]; - if(value!==undefined&&value!==null)input.value=String(value); - }); - f.elements.smart_fan.checked=source.smart_fan!==false; - if(f.elements.mode_policy) f.elements.mode_policy.value=source.inherit_house_mode===false?(source.mode||'cool'):'house'; - - f.elements.id.value=protectedValues.id; - f.elements.name.value=protectedValues.name; - f.elements.device_id.value=protectedValues.device_id; - f.elements.sensor_source.value=protectedValues.sensor_source; - f.elements.ha_entity_id.value=protectedValues.ha_entity_id; - f.elements.enabled.checked=protectedValues.enabled; - f.dataset.editingZoneId=targetId; - updateZoneSensorFields(); - updateDirtyIndicator(f); - toast(tr('zones.copiedFrom',{name:source.name})); -}); - -$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:tr('automations.presetHot'),trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:tr('automations.presetCold'),trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:tr('automations.presetMorning'),trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:tr('automations.presetNightOff'),trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; }); - -$('#tokenForm').addEventListener('submit', async event => { - event.preventDefault(); - const form = event.currentTarget; - if (!validateForm(form) || form.dataset.submitting === 'true') return; - setFormBusy(form, true, 'auth.connecting'); - app.token = new FormData(form).get('token').trim(); - localStorage.setItem('gree_controller_token', app.token); - if (app.ws) app.ws.close(); - try { await loadBootstrap(); } - finally { setFormBusy(form, false); } -}); - -$('#discoverForm').addEventListener('submit', async event => { - event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - await runFormTask(form, async () => { - const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}}); - form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []); - }, {busyKey:'actions.discovering'}); -}); - -$('#discoveryNamesForm').addEventListener('submit', async event => { - event.preventDefault(); - const form = event.currentTarget; - const inputs = $$('input[data-device-id]', form); - await runFormTask(form, async () => { - const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}}))); - updated.forEach(updateDevice); - form.closest('dialog').close(); - renderAll(); - toast(tr('common.saved')); - }); -}); - -$('#renameDeviceForm').addEventListener('submit', async event => { - event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - await runFormTask(form, async () => { - const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}}); - updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); - }); -}); - -$('#deviceForm').addEventListener('submit', async event => { - event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form)); - data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked; - await runFormTask(form, async () => { - await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added')); - }); -}); - -$('#zoneForm').addEventListener('submit', async event => { - event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - const id=form.dataset.editingZoneId || raw.id; const body={ - name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked, - mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint), - cool_comfort_setpoint:parseDecimal(raw.cool_comfort_setpoint),cool_sleep_setpoint:parseDecimal(raw.cool_sleep_setpoint),cool_away_setpoint:parseDecimal(raw.cool_away_setpoint), - heat_comfort_setpoint:parseDecimal(raw.heat_comfort_setpoint),heat_sleep_setpoint:parseDecimal(raw.heat_sleep_setpoint),heat_away_setpoint:parseDecimal(raw.heat_away_setpoint), - hysteresis:parseDecimal(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds), - min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked, - sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference),sensor_stale_after_seconds:Number(raw.sensor_stale_after_seconds||300),revision:raw.revision?Number(raw.revision):null - }; - await runFormTask(form, async () => { - await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); - }); -}); - -$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields); -$('#temporaryStartKind')?.addEventListener('change', updateTemporaryThermostatFields); -$$('[data-temporary-start-delay]').forEach(button => button.addEventListener('click', () => { - const form = $('#temporaryThermostatForm'); - form.start_delay_minutes.value = button.dataset.temporaryStartDelay; -})); -$$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => { - const form = $('#temporaryThermostatForm'); - form.duration_minutes.value = button.dataset.temporaryDuration; -})); - -$('#temporaryThermostatStop')?.addEventListener('click', async event => { - const id = event.currentTarget.dataset.id; if (!id) return; - try { - event.currentTarget.disabled = true; - const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{clear_temporary_quick_thermostat:true}}); - const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; - $('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped')); - } catch (error) { toast(error.message, true); } - finally { event.currentTarget.disabled = false; } -}); - -$('#temporaryThermostatForm')?.addEventListener('submit', async event => { - event.preventDefault(); - const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); - const activeSession = form.dataset.activeSession === 'true'; - const zone = app.zones.find(item => item.id === raw.zone_id); - const existingSession = zone?.temporary_quick_thermostat; - const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind; - const request = {start_kind:startKind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)}; - if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes); - if (!activeSession && startKind === 'at') { - const startAt = new Date(raw.start_at); - if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true); - request.start_at = startAt.toISOString(); - } - if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes); - if (raw.finish_kind === 'until') { - const until = new Date(raw.until); - if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true); - const effectiveStart = activeSession ? new Date() - : startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000) - : startKind === 'at' ? new Date(raw.start_at) : new Date(); - if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true); - request.until = until.toISOString(); - } - if (['temperature_reached','temperature_stable'].includes(raw.finish_kind)) { - request.temperature_operator = raw.temperature_operator; - request.tolerance_c = parseDecimal(raw.tolerance_c); - if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes); - const safety = Number(raw.max_duration_minutes); - if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety; - } - await runFormTask(form, async () => { - const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); - const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone; - form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); - }, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'}); -}); - -$('#groupForm')?.addEventListener('submit', async event => { - event.preventDefault(); - const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - const id=raw.id || ''; - const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value); - if (!zone_ids.length) return toast(tr('groups.chooseMember'), true); - const existing=id ? app.groups.find(group => group.id===id) : null; - const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false}; - await runFormTask(form, async () => { - await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body}); - form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); - }); -}); - -$('#scheduleForm').addEventListener('submit', async event => { - event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value)); - const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:parseDecimal(raw.setpoint||23)}; - await runFormTask(form, async () => { - await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); - }); -}); - -$('#automationForm').addEventListener('submit', async event => { - event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id; - const groupTarget=raw.action_target_kind==='group'; - const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature); - const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)}; - await runFormTask(form, async () => { - await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); - }); -}); - -function currentSettingsBody() { - const settings = app.settings || {}; - const influx = settings.influxdb || {}; - const ha = settings.home_assistant || {}; - return { - controller_id: settings.controller_id || 'gree-controller', - simulator_enabled: !!settings.simulator_enabled, - poll_interval_seconds: Number(settings.poll_interval_seconds || 15), - zone_interval_seconds: Number(settings.zone_interval_seconds || 5), - discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000), - discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000', - house_mode: settings.house_mode || 'cool', - house_power_enabled: settings.house_power_enabled !== false, - control_strategy: 'setpoint', - outdoor_assist_enabled: !!settings.outdoor_assist_enabled, - history_retention_days: Number(settings.history_retention_days || 30), - 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), - alert_types: { - stale_sensor: settings.notifications?.alert_types?.stale_sensor !== false, - sensor_errors: settings.notifications?.alert_types?.sensor_errors !== false, - communication: settings.notifications?.alert_types?.communication !== false, - target_timeout: settings.notifications?.alert_types?.target_timeout !== false, - automation: settings.notifications?.alert_types?.automation !== false, - control_errors: settings.notifications?.alert_types?.control_errors !== false, - important_events: settings.notifications?.alert_types?.important_events !== false, - other: settings.notifications?.alert_types?.other !== false, - }, - }, - night_mode: { - enabled: !!settings.night_mode?.enabled, - start_time: settings.night_mode?.start_time || '22:00', - end_time: settings.night_mode?.end_time || '06:00', - max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1), - force_quiet: settings.night_mode?.force_quiet !== false, - use_native_sleep: settings.night_mode?.use_native_sleep !== false, - }, - influxdb: { - enabled: !!influx.enabled, - version: String(influx.version || '2'), - url: influx.url || '', - database: influx.database || 'gree_controller', - username: influx.username || '', - password: '', - org: influx.org || '', - bucket: influx.bucket || 'gree_controller', - token: '', - history_threshold_days: Number(influx.history_threshold_days || 30), - }, - debug: { - overlay_enabled: !!settings.debug?.overlay_enabled, - gree_frames: !!settings.debug?.gree_frames, - }, - home_assistant: { - url: ha.url || '', - token: '', - default_entity_id: ha.default_entity_id || '', - outdoor_entity_id: ha.outdoor_entity_id || '', - sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300), - allow_invalid_tls: !!ha.allow_invalid_tls, - sensor_aliases: {...(ha.sensor_aliases || {})}, - }, - }; -} - -function settingsBodyFromForm(form) { - const raw = Object.fromEntries(new FormData(form)); - const body = currentSettingsBody(); - body.controller_id = raw.controller_id; - body.simulator_enabled = form.simulator_enabled.checked; - body.poll_interval_seconds = Number(raw.poll_interval_seconds); - body.zone_interval_seconds = Number(raw.zone_interval_seconds); - body.discovery_timeout_ms = Number(raw.discovery_timeout_ms); - body.discovery_broadcast = raw.discovery_broadcast; - body.history_retention_days = Number(raw.history_retention_days); - body.history_compaction_enabled = form.history_compaction_enabled.checked; - body.event_log_retention_days = Number(raw.event_log_retention_days); - body.suppress_device_beep = form.suppress_device_beep.checked; - body.influxdb = { - enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url, - database: raw.influx_database, username: raw.influx_username, password: raw.influx_password, - org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token, - history_threshold_days: Number(raw.influx_threshold_days), - }; - body.debug = {overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked}; - body.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), - alert_types: { - stale_sensor: form.notification_alert_stale_sensor.checked, - sensor_errors: form.notification_alert_sensor_errors.checked, - communication: form.notification_alert_communication.checked, - target_timeout: form.notification_alert_target_timeout.checked, - automation: form.notification_alert_automation.checked, - control_errors: form.notification_alert_control_errors.checked, - important_events: form.notification_alert_important_events.checked, - other: form.notification_alert_other.checked, - }, - }; - return body; -} - -function nightSettingsBodyFromForm(form) { - const raw = Object.fromEntries(new FormData(form)); - const body = currentSettingsBody(); - body.night_mode = { - enabled: form.night_mode_enabled.checked, - start_time: raw.night_mode_start, - end_time: raw.night_mode_end, - max_fan_speed: Number(raw.night_mode_max_fan_speed), - force_quiet: form.night_mode_force_quiet.checked, - use_native_sleep: form.night_mode_native_sleep.checked, - }; - return body; -} - -function homeAssistantSettingsBodyFromForm(form) { - const raw = Object.fromEntries(new FormData(form)); - const body = currentSettingsBody(); - body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked; - body.home_assistant = { - url: raw.ha_url, - token: raw.ha_token, - default_entity_id: raw.ha_entity_id, - outdoor_entity_id: raw.ha_outdoor_entity_id, - sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))), - allow_invalid_tls: form.ha_allow_invalid_tls.checked, - sensor_aliases: {...(app.sensorAliases || {})}, - }; - return body; -} - -async function saveRuntimeSettings(body, notify = true) { - app.settings = await api('/api/settings', {method:'PUT', body}); - app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; - renderSettings(); - renderNightSettings(); - renderHomeAssistantSettings(); - renderHouseClimate(); - renderSimulationModeBanner(); - renderSystemInfo(); - renderDebugOverlay(); - scheduleControlPlanLoad(); - if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); - if (notify) toast(tr('common.saved')); - return app.settings; -} - -$('#settingsForm').addEventListener('submit', async event => { - event.preventDefault(); const form = event.currentTarget; - await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true)); -}); - -$('#nightModeForm')?.addEventListener('submit', async event => { - event.preventDefault(); const form = event.currentTarget; - await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true)); -}); - -$('#homeAssistantForm')?.addEventListener('submit', async event => { - event.preventDefault(); const form = event.currentTarget; - await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true)); -}); - -$('#addSensorAlias')?.addEventListener('click', () => { - const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName'); - const entity = entityInput.value.trim(), alias = aliasInput.value.trim(); - if (!entity || !alias) return toast(tr('settings.aliasRequired'), true); - app.sensorAliases[entity] = alias; - entityInput.value = ''; aliasInput.value = ''; - renderSensorAliases(); renderHistoryNavigation(); - updateDirtyIndicator($('#homeAssistantForm')); -}); - -document.addEventListener('input', event => { - const input = event.target.closest('[data-sensor-alias]'); if (!input) return; - const entity = input.dataset.sensorAlias, alias = input.value.trim(); - if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity]; - updateDirtyIndicator($('#homeAssistantForm')); -}); - -document.addEventListener('click', event => { - const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return; - delete app.sensorAliases[button.dataset.clearSensorAlias]; - renderSensorAliases(); renderHistoryNavigation(); - updateDirtyIndicator($('#homeAssistantForm')); -}); - -$('#saveLogRetention')?.addEventListener('click', async () => { - const days = Number($('#logRetentionDays')?.value || 30); - try { - const result = await api('/api/events/retention', {method:'PUT', body:{days}}); - app.settings.event_log_retention_days = result.days; - $('#settingsForm').event_log_retention_days.value = result.days; - renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days})); - } catch (error) { toast(error.message, true); } -}); - -$('#createAccessToken').addEventListener('click', async event => { - const button = event.currentTarget; - button.disabled = true; - try { - const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}}); - if (result.item) app.accessTokens.unshift(result.item); - renderAccessTokens(); - $('#generatedAccessToken').value = result.token || ''; - openDialog('generatedTokenDialog'); - toast(tr('toast.tokenCreated')); - } catch (error) { toast(error.message, true); } - finally { button.disabled = false; } -}); - -$('#copyAccessToken').addEventListener('click', async () => { - const input = $('#generatedAccessToken'); - try { - await navigator.clipboard.writeText(input.value); - toast(tr('toast.tokenCopied')); - } catch (_) { - input.select(); - document.execCommand('copy'); - toast(tr('toast.tokenCopied')); - } -}); - -$('#haTest').addEventListener('click', async event => { - const form=$('#homeAssistantForm'), button=event.currentTarget; - if (!validateForm(form)) return; - button.disabled = true; - const idle = button.textContent; - button.textContent = tr('settings.testingHa'); - try { - await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false); - const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null; - const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}}); - markFormClean(form); - toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); - } catch(error){presentFormError(form,error);} - finally { button.disabled=false; button.textContent=idle; } -}); - -$('#simulationRefresh')?.addEventListener('click', async () => { - try { await loadControlPlan(); toast(tr('common.updated')); } - catch (error) { toast(error.message, true); } -}); - -$('#exportSettings').addEventListener('click', async () => { - try { - const data = await api('/api/settings/export'); - const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = `gree-controller-settings-${new Date().toISOString().slice(0,10)}.json`; - document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href); - toast(tr('toast.exported')); - } catch (error) { toast(error.message, true); } -}); - -$('#importSettings').addEventListener('click', () => $('#importSettingsFile').click()); -$('#importSettingsFile').addEventListener('change', async event => { - const file = event.target.files?.[0]; if (!file) return; - try { - if (!confirm(tr('settings.importConfirm'))) return; - const body = JSON.parse(await file.text()); - await api('/api/settings/import', {method:'POST', body}); - app.debugBacklogLoaded = false; - await loadBootstrap(); - toast(tr('toast.imported')); - } catch (error) { toast(error.message, true); } - finally { event.target.value = ''; } -}); - -document.addEventListener('change', event => { - const target=event.target; - if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} - else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} - else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} - else if(target.name==='influx_version') updateInfluxFields(); -}); - -window.addEventListener('popstate', applyRouteFromLocation); - -let historyResizeTimer; -window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } }); -window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); }); -if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{})); - -async function startApplication() { - observeTopbarHeight(); - applyTheme(); - await loadLanguages(); - applyTranslations(); - setupFormUx(); - updateZoneSensorFields(); - updateSchedulePresetField(); - await loadBootstrap(); - applyRouteFromLocation(); - if (location.pathname === '/' || location.pathname === `${APP_BASE}/`) updateBrowserUrl('/dashboard', true); -} - -setInterval(updateLocalThermostatCountdowns, 1000); -setInterval(updateTemporaryThermostatCountdowns, 1000); -startApplication().catch(error => console.error('Application startup failed:', error)); diff --git a/web/index.html b/web/index.html index 2aa751e..aa5e4ba 100644 --- a/web/index.html +++ b/web/index.html @@ -7,8 +7,8 @@ - - + + GREE Controller @@ -588,6 +588,6 @@
    - + diff --git a/web/js/README.md b/web/js/README.md new file mode 100644 index 0000000..b0e4b1d --- /dev/null +++ b/web/js/README.md @@ -0,0 +1,16 @@ +# Frontend JavaScript sources + +`build.rs` concatenates the files listed in `APP_JS_MODULES` into one generated `app.bundle.js` in Cargo's `OUT_DIR`. +The server exposes that bundle as `/app-.js`, so the browser downloads one application script and can cache it immutably. + +Source responsibilities: + +- `router.js` owns application URLs, browser history, route parsing and URL-to-view mapping. +- `navigation.js` owns view/tab UI transitions and navigation-related controls. +- the remaining files are split by feature or shared responsibility. + +The Rust HTTP router serves `index.html` only for the explicitly supported SPA routes. Unknown paths return the standalone `web/404.html` page with HTTP `404` instead of silently opening the dashboard. + +`web/app.js` is intentionally not used and should not exist. Do not recreate it manually; the bundle is generated at build time. + +Keep the module order in `build.rs` explicit. These files intentionally share one application scope and are bundled before being served to the browser. diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js new file mode 100644 index 0000000..b71f4ce --- /dev/null +++ b/web/js/bootstrap.js @@ -0,0 +1,29 @@ +async function loadBootstrap(showMessage = false) { + if (app.loading) return; + app.loading = true; + try { + const data = await api('/api/bootstrap'); + app.devices = data.devices || []; + app.zones = data.zones || []; + app.groups = data.groups || []; + app.schedules = data.schedules || []; + app.automations = data.automations || []; + app.accessTokens = data.access_tokens || []; + app.settings = data.settings || null; + app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; + app.system = data.system || {}; + app.systemSnapshotAt = Date.now(); + app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; + renderAll(); + loadControlPlan(); + if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); + if (showMessage) toast(tr('common.updated')); + if ($('#tokenDialog').open) $('#tokenDialog').close(); + connectWebSocket(); + } catch (error) { + if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true); + } finally { + app.loading = false; + } +} + diff --git a/web/js/charts.js b/web/js/charts.js new file mode 100644 index 0000000..10eff19 --- /dev/null +++ b/web/js/charts.js @@ -0,0 +1,454 @@ +function encodeChartSpec(series) { + try { + const raw = JSON.stringify(series || []); + return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); + } catch (_) { return ''; } +} + +function decodeChartSpec(encoded) { + try { + const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '='); + const value = JSON.parse(atob(padded)); + return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : []; + } catch (_) { return []; } +} + +function historyEntityOptions() { + const zones = app.zones.map(zone => ``).join(''); + const devices = app.devices.map(device => ``).join(''); + const entities = [...new Set([ + ...app.historyData.sensors.map(row => row.entity_id), + ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), + app.settings?.home_assistant?.outdoor_entity_id, + ].filter(Boolean))].sort(); + const sensors = entities.map(entity => ``).join(''); + return {zones, devices, sensors, entities}; +} + +function renderHistoryNavigation() { + $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); + const host = $('#historyContextControls'); if (!host) return; + const options = historyEntityOptions(); + if (app.historyTab === 'zones') { + host.innerHTML = ``; + const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone; + } else if (app.historyTab === 'devices') { + host.innerHTML = ``; + const select=$('#historyDeviceSelect'); if ([...select.options].some(option=>option.value===app.historyDevice)) select.value=app.historyDevice; + } else if (app.historyTab === 'sensors') { + host.innerHTML = ``; + const select=$('#historySensorSelect'); if ([...select.options].some(option=>option.value===app.historySensor)) select.value=app.historySensor; + } else if (app.historyTab === 'custom') { + host.innerHTML = `${esc(tr('history.customHint'))}`; + } else { + host.innerHTML = `${esc(tr('history.overviewHint'))}`; + } +} + +async function loadHistory() { + if (app.historyLoading) return; + app.historyLoading = true; + const hours = $('#historyHours')?.value || '24'; + try { + const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`); + app.historyData = {zones:data.zones || [], devices:data.devices || [], sensors:data.sensors || []}; + app.historyCounts = data.counts || {}; + renderHistoryNavigation(); + renderHistoryPage(); + if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true); + } catch (error) { + toast(error.message, true); + renderHistoryPage(); + } finally { app.historyLoading = false; } +} + +const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue']; +const MAX_CHART_ZOOM = 16; +const chartRuntime = new Map(); + +function chartSeriesKey(item, index) { + return item.key || `${index}:${item.label}`; +} + +function isChartSeriesHidden(chartId, item, index) { + return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item,index)]; +} + +function setChartSeriesHidden(chartId, item, index, hidden) { + if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {}; + const key = chartSeriesKey(item,index); + if (hidden) app.chartHiddenSeries[chartId][key] = true; + else delete app.chartHiddenSeries[chartId][key]; +} + +function updateChartZoomControls(id) { + const canvas=document.getElementById(id); + const card=canvas?.closest('.history-chart-card'); + if(!card) return; + const zoom=clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM); + const reset=card.querySelector('[data-chart-zoom="reset"]'); + const out=card.querySelector('[data-chart-zoom="out"]'); + const zoomIn=card.querySelector('[data-chart-zoom="in"]'); + if(reset) reset.textContent=`${Math.round(zoom*100)}%`; + if(out) out.disabled=zoom<=1; + if(zoomIn) zoomIn.disabled=zoom>=MAX_CHART_ZOOM; +} + +function redrawHistoryChart(id) { + const runtime=chartRuntime.get(id); + const canvas=document.getElementById(id); + if(!runtime || !canvas) return; + canvas.dataset.chartZoom=String(clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM)); + drawLineChart(canvas,runtime.series,runtime.rows,runtime.options); + renderLegend(document.getElementById(`${id}Legend`),runtime.series); + updateChartZoomControls(id); +} + +function setChartZoom(id, value, anchorRatio=null, anchorViewportX=null) { + const canvas=document.getElementById(id); + const wrap=canvas?.parentElement; + if(!canvas || !wrap) return; + const oldWidth=canvas.getBoundingClientRect().width || 1; + const ratio=Number.isFinite(anchorRatio) ? clamp(anchorRatio,0,1) : clamp((wrap.scrollLeft+wrap.clientWidth/2)/oldWidth,0,1); + const viewportX=Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth/2; + const next=clamp(Number(value) || 1,1,MAX_CHART_ZOOM); + app.chartZooms[id]=next; + redrawHistoryChart(id); + const nextCanvas=document.getElementById(id); + const nextWrap=nextCanvas?.parentElement; + if(!nextCanvas || !nextWrap) return; + const nextWidth=nextCanvas.getBoundingClientRect().width || 1; + const previousBehavior=nextWrap.style.scrollBehavior; + nextWrap.style.scrollBehavior='auto'; + nextWrap.scrollLeft=Math.max(0,ratio*nextWidth-viewportX); + nextWrap.style.scrollBehavior=previousBehavior; +} + +function cssColor(name, fallback) { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +function timeLabel(ts, hours) { + const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'}; + return new Date(ts).toLocaleString(locale(), options); +} + +function prepareCanvas(canvas, height) { + const rect = canvas.getBoundingClientRect(); + const wrapWidth = Math.floor(canvas.parentElement?.clientWidth || rect.width || 720); + const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4); + const width = Math.max(720, Math.floor(Math.max(720, wrapWidth) * zoom)); + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; canvas.height = height * dpr; + canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; + const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); + ctx.clearRect(0,0,width,height); + return {ctx,width,height}; +} + +function drawEmptyChart(canvas, height=340) { + if (!canvas) return; + const {ctx,width} = prepareCanvas(canvas,height); + ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center'; + ctx.fillText(tr('history.noData'), width/2, height/2); + const wrap=canvas.parentElement; + const tooltip=wrap?.querySelector('.chart-tooltip'); + const line=wrap?.querySelector('.chart-hover-line'); + if(tooltip) tooltip.hidden=true; + if(line) line.hidden=true; + canvas.onpointermove=null; canvas.onpointerleave=null; canvas.onpointerdown=null; canvas.onpointerup=null; canvas.onpointercancel=null; canvas.onkeydown=null; +} + +function chartPreciseTime(timestamp) { + return new Intl.DateTimeFormat(locale(), { + year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' + }).format(new Date(timestamp)); +} + +function nearestPoint(points, targetTs) { + if (!points.length) return null; + let lo=0, hi=points.length-1; + while(lo0?points[lo-1]:null; + if(!before) return after; + return Math.abs(before.ts-targetTs) <= Math.abs(after.ts-targetTs) ? before : after; +} + +function formatChartTooltipValue(item, point, binaryLabels) { + if (!point || !Number.isFinite(point.value)) return '—'; + if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row)); + const formatted = Number(point.value).toLocaleString(locale(), {minimumFractionDigits:1, maximumFractionDigits:2}); + return binaryLabels ? formatted : `${formatted} °C`; +} + +function bindChartTooltip(canvas, series, sortedRows, geometry) { + const wrap=canvas?.parentElement; + const tooltip=wrap?.querySelector('.chart-tooltip'); + const line=wrap?.querySelector('.chart-hover-line'); + const selection=wrap?.querySelector('.chart-selection'); + if(!canvas || !wrap || !tooltip || !line) return; + + const {pad,width,height,firstTs,lastTs,binaryLabels}=geometry; + const plotWidth=width-pad.left-pad.right; + const timestamps=[...new Set(sortedRows.map(row=>new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a,b)=>a-b); + const timestampPoints=timestamps.map(ts=>({ts,value:ts})); + const pointsBySeries=series.map(item=>({ + item, + points:sortedRows.map(row=>({row,ts:new Date(row.timestamp).getTime(),value:item.value(row)})) + .filter(point=>Number.isFinite(point.ts)&&Number.isFinite(point.value)) + .sort((a,b)=>a.ts-b.ts) + })); + const touches=new Map(); + let drag=null; + let pinch=null; + let touchPan=null; + + const hide=()=>{ tooltip.hidden=true; line.hidden=true; }; + const hideSelection=()=>{ if(selection) selection.hidden=true; }; + const canvasX=clientX=>{ + const rect=canvas.getBoundingClientRect(); + return clamp(clientX-rect.left,pad.left,width-pad.right); + }; + const showAtTimestamp=(targetTs)=>{ + if(!timestamps.length) return hide(); + const snapped=nearestPoint(timestampPoints,targetTs)?.ts; + if(!Number.isFinite(snapped)) return hide(); + const px=pad.left + (snapped-firstTs)/Math.max(1,lastTs-firstTs)*plotWidth; + const values=pointsBySeries.map(({item,points})=>({item,point:nearestPoint(points,snapped)})).filter(entry=>entry.point); + if(!values.length) return hide(); + + $$('.chart-tooltip').forEach(node=>{ if(node!==tooltip) node.hidden=true; }); + $$('.chart-hover-line').forEach(node=>{ if(node!==line) node.hidden=true; }); + tooltip.innerHTML=`${esc(chartPreciseTime(snapped))}
    ${values.map(({item,point})=>{ + const sampleTime=Math.abs(point.ts-snapped)>1000?`${esc(chartPreciseTime(point.ts))}`:''; + return `
    ${esc(item.label)}${esc(formatChartTooltipValue(item,point,binaryLabels))}${sampleTime}
    `; + }).join('')}
    `; + tooltip.hidden=false; + line.hidden=false; + line.style.left=`${px}px`; + line.style.top=`${pad.top}px`; + line.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`; + + const minLeft=wrap.scrollLeft+8; + const maxLeft=wrap.scrollLeft+wrap.clientWidth-tooltip.offsetWidth-8; + let tooltipLeft=px+12; + if(tooltipLeft+tooltip.offsetWidth>wrap.scrollLeft+wrap.clientWidth-8) tooltipLeft=px-tooltip.offsetWidth-12; + tooltip.style.left=`${Math.max(minLeft,Math.min(tooltipLeft,Math.max(minLeft,maxLeft)))}px`; + tooltip.style.top=`${pad.top+8}px`; + canvas.dataset.chartHoverTs=String(snapped); + }; + + const showFromClientX=(clientX)=>{ + const localX=canvasX(clientX); + const targetTs=firstTs + ((localX-pad.left)/Math.max(1,plotWidth))*(lastTs-firstTs); + showAtTimestamp(targetTs); + }; + + const updateSelection=(currentX)=>{ + if(!drag || !selection) return; + const left=Math.min(drag.startX,currentX); + const right=Math.max(drag.startX,currentX); + drag.lastX=currentX; + drag.moved=Math.abs(right-left)>=7; + if(!drag.moved) return hideSelection(); + selection.hidden=false; + selection.style.left=`${left}px`; + selection.style.width=`${Math.max(1,right-left)}px`; + selection.style.top=`${pad.top}px`; + selection.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`; + }; + + canvas.onpointerdown=event=>{ + if(event.pointerType==='mouse'){ + if(event.button!==0) return; + const startX=canvasX(event.clientX); + drag={pointerId:event.pointerId,startX,lastX:startX,moved:false}; + hide(); hideSelection(); + canvas.setPointerCapture?.(event.pointerId); + event.preventDefault(); + return; + } + if(event.pointerType==='touch'){ + touches.set(event.pointerId,{x:event.clientX,y:event.clientY}); + if(touches.size===1){ + touchPan={pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,scrollLeft:wrap.scrollLeft,moved:false}; + showFromClientX(event.clientX); + } + if(touches.size===2){ + touchPan=null; + const points=[...touches.values()]; + const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y); + const midX=(points[0].x+points[1].x)/2; + const rect=canvas.getBoundingClientRect(); + const wrapRect=wrap.getBoundingClientRect(); + pinch={ + startDistance:Math.max(1,distance), + startZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM), + baseWidth:(rect.width||width)/clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM), + anchorRatio:clamp((midX-rect.left)/Math.max(1,rect.width),0,1), + viewportX:clamp(midX-wrapRect.left,0,wrap.clientWidth), + previewZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM), + }; + hide(); + event.preventDefault(); + } + } + }; + + canvas.onpointermove=event=>{ + if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ + updateSelection(canvasX(event.clientX)); + event.preventDefault(); + return; + } + if(event.pointerType==='touch' && touches.has(event.pointerId)){ + touches.set(event.pointerId,{x:event.clientX,y:event.clientY}); + if(pinch && touches.size>=2){ + const points=[...touches.values()].slice(0,2); + const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y); + pinch.previewZoom=clamp(pinch.startZoom*(distance/pinch.startDistance),1,MAX_CHART_ZOOM); + const previewWidth=pinch.baseWidth*pinch.previewZoom; + canvas.style.width=`${previewWidth}px`; + wrap.scrollLeft=Math.max(0,pinch.anchorRatio*previewWidth-pinch.viewportX); + const reset=canvas.closest('.history-chart-card')?.querySelector('[data-chart-zoom="reset"]'); + if(reset) reset.textContent=`${Math.round(pinch.previewZoom*100)}%`; + event.preventDefault(); + } else if(touchPan?.pointerId===event.pointerId && touches.size===1){ + const dx=event.clientX-touchPan.startX; + const dy=event.clientY-touchPan.startY; + if(Math.abs(dx)>6 && Math.abs(dx)>Math.abs(dy)){ + touchPan.moved=true; + hide(); + wrap.scrollLeft=Math.max(0,touchPan.scrollLeft-dx); + event.preventDefault(); + } + } + return; + } + showFromClientX(event.clientX); + }; + + const finishPointer=event=>{ + if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ + const currentX=canvasX(event.clientX); + const selectedWidth=Math.abs(currentX-drag.startX); + const center=(currentX+drag.startX)/2; + const moved=drag.moved && selectedWidth>=12; + drag=null; hideSelection(); + try{ canvas.releasePointerCapture?.(event.pointerId); }catch(_){} + if(moved){ + const currentZoom=clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM); + const factor=Math.max(1,wrap.clientWidth/Math.max(1,selectedWidth)); + setChartZoom(canvas.id,currentZoom*factor,center/Math.max(1,width),wrap.clientWidth/2); + }else showFromClientX(event.clientX); + return; + } + if(event.pointerType==='touch'){ + const wasPan=touchPan?.pointerId===event.pointerId ? touchPan : null; + touches.delete(event.pointerId); + if(pinch && touches.size<2){ + const completed=pinch; + pinch=null; + setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX); + } else if(wasPan && !wasPan.moved) showFromClientX(event.clientX); + if(touches.size===0) touchPan=null; + } + }; + + canvas.onpointerup=finishPointer; + canvas.onpointercancel=event=>{ + if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ drag=null; hideSelection(); } + if(event.pointerType==='touch'){ + touches.delete(event.pointerId); + if(touchPan?.pointerId===event.pointerId) touchPan=null; + if(pinch && touches.size<2){ + const completed=pinch; + pinch=null; + setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX); + } else hide(); + } + }; + canvas.onpointerleave=event=>{ if(event.pointerType!=='touch' && !drag) hide(); }; + canvas.onkeydown=event=>{ + if(!['ArrowLeft','ArrowRight'].includes(event.key) || !timestamps.length) return; + event.preventDefault(); + const current=Number(canvas.dataset.chartHoverTs); + let index=Number.isFinite(current)?timestamps.findIndex(ts=>ts===current):-1; + if(index<0) index=event.key==='ArrowRight'?0:timestamps.length-1; + else index=clamp(index+(event.key==='ArrowRight'?1:-1),0,timestamps.length-1); + showAtTimestamp(timestamps[index]); + }; +} + +function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) { + if (!canvas) return; + const options={height,minValue,maxValue,binaryLabels}; + if(canvas.id) chartRuntime.set(canvas.id,{series,rows,options}); + const visibleSeries=series.filter((item,index)=>!isChartSeriesHidden(canvas.id,item,index)); + if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height); + const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp)); + const {ctx,width} = prepareCanvas(canvas,height); + const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'); + const pad={left:54,right:20,top:20,bottom:42}; + const allValues=[]; + visibleSeries.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); })); + if (!allValues.length) return drawEmptyChart(canvas,height); + let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue; + let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue; + if (max-min < 2) { min-=1; max+=1; } + const firstTs = new Date(sortedRows[0].timestamp).getTime(); + const lastTs = new Date(sortedRows[sortedRows.length-1].timestamp).getTime(); + const span = Math.max(1,lastTs-firstTs); + const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right); + const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom); + ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1; + for(let i=0;i<=5;i++){ + const value=min+(max-min)*i/5, py=y(value); + ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke(); + ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3); + } + const ticks=5; + for(let i=0;i<=ticks;i++){ + const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]); + ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14); + } + visibleSeries.forEach(item => { + ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]); + let started=false, prev=null; + sortedRows.forEach(row => { + const value=item.value(row); if(!Number.isFinite(value)) return; + const px=x(row),py=y(value); + if(!started||prev===null){ctx.moveTo(px,py);started=true;} + else if(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py); + prev=value; + }); + ctx.stroke(); ctx.setLineDash([]); + }); + bindChartTooltip(canvas,visibleSeries,sortedRows,{pad,width,height,firstTs,lastTs,binaryLabels}); +} + +function renderLegend(host, series) { + if (!host) return; + const chartId=host.id.replace(/Legend$/,''); + host.innerHTML=series.map((item,index)=>{ + const hidden=isChartSeriesHidden(chartId,item,index); + const title=tr(hidden?'history.legendShow':'history.legendHide',{name:item.label}); + return ``; + }).join(''); +} + +function historyChartMarkup(id, title, hint, compact=false) { + const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM); + const percent = Math.round(zoom * 100); + return `

    ${esc(title)}

    ${esc(hint || '')}

    ${esc(tr('history.hoverHint'))}
    `; +} + +function historySeriesColor(index) { + return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`); +} + diff --git a/web/js/core.js b/web/js/core.js new file mode 100644 index 0000000..c090e0d --- /dev/null +++ b/web/js/core.js @@ -0,0 +1,324 @@ +'use strict'; + +const APP_BASE = (() => { + const src = document.currentScript?.src || ''; + try { + const path = new URL(src, location.href).pathname; + const slash = path.lastIndexOf('/'); + const filename = path.slice(slash + 1); + return /^app(?:-[a-f0-9]+)?\.js$/i.test(filename) ? path.slice(0, slash).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=${APP_BASE || '/'}; SameSite=Lax`; +}; + +const DEFAULT_LANGUAGE = 'en'; +const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE; +const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system'; + +const app = { + devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, + token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, + currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', + languages: [], translations: {}, locales: {}, + historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, + historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, + customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, zoneTemperatureTimers: {}, + controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, + simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), +}; +try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); +const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); +const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB'; +const tr = (key, params = {}) => { + const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key; + return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`); +}; +const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; +const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value); +const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); +const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode); +const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); +const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; +const localResumeSeconds = value => { + const timestamp = value ? new Date(value).getTime() : NaN; + return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null; +}; +const formatCountdown = seconds => { + if (!Number.isFinite(seconds)) return '--:--'; + const safe = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safe / 60); + const remainder = safe % 60; + return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; +}; +const formatExtendedCountdown = seconds => { + if (!Number.isFinite(seconds)) return '∞'; + const safe = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const remainder = safe % 60; + return hours > 0 + ? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}` + : `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; +}; +function updateLocalThermostatCountdowns() { + $$('[data-local-resume-countdown]').forEach(node => { + const remaining = localResumeSeconds(node.dataset.localResumeCountdown); + node.textContent = remaining !== null && remaining > 0 + ? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)}) + : tr('zones.localThermostatResuming'); + }); +} +function temporarySessionStatus(zone) { + const session = zone?.temporary_quick_thermostat; + if (!session) return null; + const now = Date.now(); + const secondsUntil = value => { + const timestamp = value ? new Date(value).getTime() : NaN; + return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null; + }; + const safetyRemaining = secondsUntil(session.safety_expires_at); + const hardRemaining = secondsUntil(session.expires_at); + const startRemaining = secondsUntil(session.started_at); + const pending = !session.activated_at; + const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint); + const targetText = Number.isFinite(target) ? target.toFixed(1) : '--'; + const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1); + const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort'; + const condition = tr(operatorKey, {target:targetText, tolerance}); + + if (session.state === 'waiting_master') { + return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true}; + } + if (session.state === 'paused_manual') { + return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending}; + } + if (pending) { + return { + countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00', + detail:startRemaining != null && startRemaining > 0 + ? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)}) + : tr('zones.temporaryStartingStatus'), + kind:session.finish_kind, + pending:true, + }; + } + + if (session.finish_kind === 'temperature_stable') { + const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN; + const holdSeconds = Number(session.hold_seconds || 0); + if (Number.isFinite(started)) { + const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000)); + const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining); + return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind}; + } + return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind}; + } + if (session.finish_kind === 'temperature_reached') { + return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind}; + } + const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus'; + return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind}; +} +function updateTemporaryThermostatCountdowns() { + $$('[data-temporary-countdown-zone]').forEach(node => { + const zone = app.zones.find(item => item.id === node.dataset.temporaryCountdownZone); + const status = temporarySessionStatus(zone); + if (!status) return; + node.textContent = status.countdown; + node.title = status.detail; + }); + const dialog = $('#temporaryThermostatDialog'); + if (dialog?.open) { + const zone = app.zones.find(item => item.id === $('#temporaryThermostatForm')?.elements.zone_id.value); + const status = temporarySessionStatus(zone); + if (status) { + $('#temporaryThermostatActiveCountdown').textContent = status.countdown; + $('#temporaryThermostatActiveDetail').textContent = status.detail; + const label = $('#temporaryThermostatActiveLabel'); + if (label) label.textContent = tr(status.pending ? 'zones.temporaryScheduled' : 'zones.temporaryActive'); + } + } +} +const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; + +function syncTopbarHeight() { + const topbar = $('.topbar'); + if (!topbar) return; + document.documentElement.style.setProperty('--topbar-height', `${Math.ceil(topbar.getBoundingClientRect().height)}px`); +} + +function observeTopbarHeight() { + const topbar = $('.topbar'); + if (!topbar) return; + syncTopbarHeight(); + if ('ResizeObserver' in window) { + const observer = new ResizeObserver(syncTopbarHeight); + observer.observe(topbar); + } else { + window.addEventListener('resize', syncTopbarHeight); + } +} + +function updateConnectionIndicator(status) { + app.connectionStatus = status || 'connecting'; + const node = $('#connectionLabel'); + const label = tr(`status.${app.connectionStatus}`); + if (node) { + node.className = `connection-dot ${app.connectionStatus}`; + node.setAttribute('aria-label', label); + node.title = label; + } + const banner = $('#connectionBanner'); + const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus); + document.body.classList.toggle('connection-lost', disconnected); + if (banner) { + banner.hidden = !disconnected; + const title = $('#connectionBannerTitle'); + const text = $('#connectionBannerText'); + if (title) title.textContent = label; + if (text) text.textContent = tr('status.offlineHint'); + } + renderSystemInfo(); +} + +function updateToolbarControls() { + const languageLabel = $('#languageLabel'); + if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase(); + const themeSelect = $('#themeSelect'); + const themeIcon = $('#themeIcon'); + const icons = {system:'◐',light:'☀',dark:'☾'}; + if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system; + if (themeSelect) { + themeSelect.value = app.theme; + const label = `${tr('controls.theme')}: ${tr(`theme.${app.theme}`)}`; + themeSelect.setAttribute('aria-label', label); + themeSelect.closest('.toolbar-picker')?.setAttribute('title', label); + } +} + +function applyTheme() { + const resolved = app.theme === 'light' || app.theme === 'dark' + ? app.theme + : (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); + document.documentElement.dataset.theme = resolved; + const meta = $('#themeColorMeta'); + if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515'; + updateToolbarControls(); + drawCurrentChartIfVisible(); +} + +function applyTranslations() { + document.documentElement.lang = app.language; + $$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); }); + $$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); }); + $$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); }); + $$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); }); + $$('[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; + updateToolbarControls(); + updateConnectionIndicator(app.connectionStatus); + renderAll(); + if (app.currentView === 'logs') loadLogs(); + if (app.currentView === 'history' && app.zones.length) loadHistory(); +} + +function setLanguage(language) { + const available = app.languages.some(item => item.code === language); + app.language = available ? language : DEFAULT_LANGUAGE; + setCookie('gree_controller_language', app.language); + applyTranslations(); +} + +function renderLanguageOptions() { + const select = $('#languageSelect'); + if (!select) return; + select.innerHTML = app.languages.map(item => { + const label = item.native_name || item.name || item.code.toUpperCase(); + return ``; + }).join(''); + select.value = app.language; + updateToolbarControls(); +} + +async function loadLanguages() { + try { + 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(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}; + })); + + app.languages = loaded.map(({item}) => item); + app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}])); + app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code])); + app.language = app.languages.some(item => item.code === preferredLanguage) + ? preferredLanguage + : (manifest.default || DEFAULT_LANGUAGE); + if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE; + renderLanguageOptions(); + } catch (error) { + console.error('Unable to load language packs:', error); + app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}]; + app.translations = {[DEFAULT_LANGUAGE]: {}}; + app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'}; + app.language = DEFAULT_LANGUAGE; + renderLanguageOptions(); + } +} + +function setTheme(theme) { + app.theme = ['system', 'light', 'dark'].includes(theme) ? theme : 'system'; + setCookie('gree_controller_theme', app.theme); + applyTheme(); +} + +function cycleTheme() { + const modes = ['system', 'light', 'dark']; + const index = modes.indexOf(app.theme); + setTheme(modes[(index + 1) % modes.length]); +} + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + headers.set('Accept', 'application/json'); + if (app.token) headers.set('Authorization', `Bearer ${app.token}`); + let body = options.body; + if (body !== undefined && body !== null && typeof body !== 'string') { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(body); + } + const response = await fetch(withBase(path), {...options, headers, body}); + if (response.status === 401) { + showTokenDialog(); + throw new Error(tr('auth.invalid')); + } + if (!response.ok) { + let message = tr('error.http', {status: response.status}); + try { message = (await response.json()).error || message; } catch (_) {} + throw new Error(message); + } + if (response.status === 204) return null; + return response.json(); +} + diff --git a/web/js/dashboard.js b/web/js/dashboard.js new file mode 100644 index 0000000..20ae239 --- /dev/null +++ b/web/js/dashboard.js @@ -0,0 +1,401 @@ +function renderAll() { + renderSummary(); + renderHouseClimate(); + renderGroups(); + renderControlPlan(); + renderSimulationPage(); + renderDevices(); + renderZones(); + renderSchedules(); + renderAutomations(); + renderAccessTokens(); + fillSelects(); + renderSettings(); + renderNightSettings(); + renderHomeAssistantSettings(); + renderSimulationModeBanner(); + renderDebugOverlay(); +} + +function renderSummary() { + const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite); + const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null; + const online = app.devices.filter(d => d.online).length; + const active = app.devices.filter(d => d.power).length; + const demand = app.zones.filter(z => z.enabled && z.demand).length; + const toolbarStatus = $('#toolbarRuntimeStatus'); + if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', {active, total:app.devices.length}); + $('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}°C`; + const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : ''; + $('#summaryText').textContent = app.devices.length + ? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix}) + : tr('dashboard.empty'); + $('#metrics').innerHTML = [ + [tr('dashboard.metricOnline'), `${online}/${app.devices.length}`], + [tr('dashboard.metricActive'), active], + [tr('dashboard.metricDemand'), demand], + ].map(([label,value]) => `
    ${esc(label)}${esc(value)}
    `).join(''); + renderSystemInfo(); +} + +function renderHouseClimate() { + const node = $('#houseClimate'); if (!node || !app.settings) return; + const mode = app.settings.house_mode || 'cool'; + const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto'); + const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null; + const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature); + const powerEnabled = app.settings.house_power_enabled !== false; + const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff'); + if (powerOn) { powerOn.classList.toggle('active', powerEnabled); powerOn.setAttribute('aria-pressed', String(powerEnabled)); } + if (powerOff) { powerOff.classList.toggle('active', !powerEnabled); powerOff.setAttribute('aria-pressed', String(!powerEnabled)); } + node.innerHTML = `
    ${esc(tr('house.seasonMode'))}

    ${esc(tr('house.smartThermostat'))}

    ${esc(tr('house.setpointStrategy'))}

    ${esc(tr('house.outdoor'))}${esc(outdoor)}
    +
    + + + +
    +
    ${['auto','comfort','sleep','away'].map(p=>``).join('')}
    `; +} + +function planEventMarkup(event) { + const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—'; + const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`; + return `
  • ${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}
  • `; +} + +function automationTriggerLabel(item) { + if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'}); + const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow'; + return tr(key, {temperature: fmtTemp(item.threshold)}); +} + +function planZoneEffectiveEnabled(zone) { + if (!zone) return undefined; + return zone.effective_enabled ?? zone.enabled ?? false; +} + +function renderControlPlan() { + const host = $('#controlPlan'); if (!host) return; + const plan = app.controlPlan; + if (!plan) { + host.innerHTML = `
    ${esc(tr('plan.loading'))}
    `; + return; + } + + const allZones = plan.zones || []; + const houseEvents = (plan.next_events || []).slice(0, 3); + const house = `
    ${esc(tr('plan.house'))}

    ${esc(houseModeLabel(plan.house_mode || 'off'))}

    ${esc((plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint'))}

    ${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}

      ${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `
    • ${esc(tr('plan.noEvents'))}
    • `}
    `; + + const groupCards = (app.groups || []).map(group => { + const memberIds = new Set(group.zone_ids || []); + const members = allZones.filter(zone => memberIds.has(zone.zone_id)); + const state = groupState(group); + const powerEnabled = group.power_enabled !== false; + const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length; + const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode); + const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset); + const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`}))) + .sort((a,b) => new Date(a.at) - new Date(b.at)).slice(0, 2); + return `
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(powerEnabled ? tr('common.on') : tr('common.off'))}
    ${esc(mode)}·${esc(preset)}

    ${esc(tr('plan.groupSummary', {zones:members.length, demand}))}

      ${events.length ? events.map(planEventMarkup).join('') : `
    • ${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}
    • `}
    `; + }).join(''); + + const zoneGroups = new Map(); + (app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => { + const names = zoneGroups.get(zoneId) || []; + names.push(group.name); + zoneGroups.set(zoneId, names); + })); + const zones = allZones.map(zone => { + const events = (zone.next_events || []).slice(0, 2); + const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1); + const groupNames = zoneGroups.get(zone.zone_id) || []; + const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice')); + const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off'); + return `
    ${esc(scope)}

    ${esc(zone.zone_name)}

    ${esc(planState)}
    ${fmtTemp(zone.current_temperature)}${esc(target)}°C

    ${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

      ${events.length ? events.map(planEventMarkup).join('') : `
    • ${esc(tr('plan.noEvents'))}
    • `}
    `; + }).join(''); + + const rules = (plan.rules || []).filter(rule => rule.enabled); + const ruleCard = rules.length ? `
    ${esc(tr('plan.rules'))}

    ${esc(tr('plan.ruleCount', {count:rules.length}))}

      ${rules.slice(0,3).map(rule=>`
    • ${esc(rule.name)} → ${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}
    • `).join('')}
    ` : ''; + host.innerHTML = house + groupCards + zones + ruleCard; +} +async function loadControlPlan() { + try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); } + catch (error) { console.warn('Unable to load control plan:', error); } +} + +function simulationTime(value, options = {}) { + if (!value) return '—'; + const date = new Date(value); + return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', ''); +} + +function simulationNumeric(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function simulationOutdoorAssist(mode, outdoor, room, target) { + if (outdoor == null || room == null || target == null) return 0; + const roomError = Math.abs(room - target); + const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1); + return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1); +} + +function simulationRoundDeviceSetpoint(mode, demand, value) { + const safe = clamp(Number(value) || 0, 16, 30); + if (mode === 'heat' && demand) return Math.ceil(safe); + if (mode === 'heat' && !demand) return Math.floor(safe); + if (mode !== 'heat' && demand) return Math.floor(safe); + return Math.ceil(safe); +} + +function simulationSmartFanSpeed(mode, room, target, outdoor, demand) { + if (!demand) return 1; + const error = Math.abs(room - target); + const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32; + if (error >= 2 || extremeWeather) return 3; + if (error >= 1) return 2; + return 0; +} + +function simulationStatusInfo(status) { + const map = { + disabled: {label: tr('common.disabled'), badge: ''}, + off: {label: tr('house.noControl'), badge: ''}, + waiting: {label: tr('simulation.waitingForData'), badge: ''}, + demand: {label: tr('simulation.stateDemand'), badge: 'active'}, + satisfied: {label: tr('simulation.stateSatisfied'), badge: ''}, + }; + return map[status] || {label: status, badge: ''}; +} + +function buildZoneSimulation(zone, planZone) { + const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id)); + const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off'; + const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature); + const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint); + const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature); + const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1); + const half = hysteresis / 2; + let status = 'waiting'; + let demand = false; + if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled'; + else if (mode === 'off') status = 'off'; + else if (current == null || target == null) status = 'waiting'; + else { + if (mode === 'heat') { + if (current <= target - half) demand = true; + else if (current >= target + half) demand = false; + else demand = !!zone?.demand; + } else { + if (current >= target + half) demand = true; + else if (current <= target - half) demand = false; + else demand = !!zone?.demand; + } + status = demand ? 'demand' : 'satisfied'; + } + const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5); + const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target); + const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist); + const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset); + const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget); + const nightActive = !!app.controlPlan?.night_mode_active; + const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5); + let fanSpeed = simulationNumeric(device?.fan_speed); + if (mode !== 'off') { + fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed; + if (nightActive) { + if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan); + else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan; + } + } + const quiet = mode === 'off' + ? tr('simulation.manual') + : nightActive && app.settings?.night_mode?.force_quiet + ? tr('simulation.quietIfSupported') + : (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual')); + const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true; + const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false + ? tr('simulation.reasonDisabled') + : mode === 'off' + ? tr('simulation.reasonHouseOff') + : current == null || target == null + ? tr('simulation.reasonWaiting') + : demand + ? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)}) + : tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)}); + return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning}; +} + +function simulationRuleAction(rule) { + const bits = []; + if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`); + if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`); + if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`); + if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`); + return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview'); +} + +function simulationGroupsForZone(zoneId) { + return app.groups.filter(group => (group.zone_ids || []).includes(zoneId)); +} + +function renderSimulationScopeControls(plan) { + const scope = $('#simulationScope'); + const target = $('#simulationTarget'); + if (!scope || !target) return; + if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units'; + scope.value = app.simulationScope; + const zones = plan?.zones || []; + const options = app.simulationScope === 'groups' + ? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))] + : [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))]; + if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all'; + target.innerHTML = options.map(option => ``).join(''); + target.value = app.simulationTarget; +} + +function simulationFilteredZones(plan) { + const zones = plan?.zones || []; + if (app.simulationScope === 'groups') { + if (app.simulationTarget === 'all') { + const grouped = new Set(app.groups.flatMap(group => group.zone_ids || [])); + return zones.filter(zone => grouped.has(zone.zone_id)); + } + const group = app.groups.find(item => item.id === app.simulationTarget); + const wanted = new Set(group?.zone_ids || []); + return zones.filter(zone => wanted.has(zone.zone_id)); + } + return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget); +} + +function simulationRuleVisible(rule, zones) { + if (app.simulationTarget === 'all') return true; + const zoneIds = new Set(zones.map(zone => zone.zone_id)); + const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean)); + if (app.simulationScope === 'groups') { + if (rule.action_group_id === app.simulationTarget) return true; + return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id); + } + const directZone = [...zoneIds][0]; + if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true; + return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id); +} + +function simulationLaneContext(planZone) { + if (app.simulationScope !== 'groups') return planZone.device_name || ''; + const groups = simulationGroupsForZone(planZone.zone_id); + return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || ''); +} + +function updateSimulationUrl() { + if (app.currentView !== 'simulation') return; + const params = new URLSearchParams(); + if (app.standaloneSimulation) params.set('standalone', '1'); + if (app.simulationScope !== 'units') params.set('scope', app.simulationScope); + if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget); + const query = params.toString(); + updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true); +} + +function renderSimulationPage() { + const summaryHost = $('#simulationSummary'); + const timelineHost = $('#simulationTimeline'); + const boardHost = $('#simulationFlowBoard'); + const rulesHost = $('#simulationRules'); + if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return; + const plan = app.controlPlan; + if (!plan) { + summaryHost.innerHTML = `
    ${esc(tr('plan.loading'))}
    `; + timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = ''; + return; + } + + renderSimulationScopeControls(plan); + const zones = simulationFilteredZones(plan); + const enabledZones = zones.filter(zone => zone.enabled); + const demandingZones = enabledZones.filter(zone => zone.demand); + const nightOn = !!plan.night_mode_active; + summaryHost.innerHTML = [ + {label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')})}, + {label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`}, + {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, + {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`}, + ].map(card => `
    ${esc(card.label)}${esc(card.value)}${esc(card.note)}
    `).join(''); + + const nodeW = 190, nodeH = 106, rowH = 190, topY = 155; + const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085}; + const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35); + const nodes = []; + const links = []; + const pathBetween = (ax, ay, bx, by, cls='') => { + const bend = Math.max(36, (bx - ax) * .45); + return ``; + }; + const verticalLink = (ax, ay, bx, by, cls='bus') => ``; + const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `
    ${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}

    ${esc(title)}

    ${esc(value)}${meta ? `

    ${esc(meta)}

    ` : ''}
    `; + + const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28; + nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')}),badge:tr('simulation.house')})); + nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''})); + + zones.forEach((planZone, index) => { + const zone = app.zones.find(item => item.id === planZone.zone_id); + const sim = buildZoneSimulation(zone, planZone); + const status = simulationStatusInfo(sim.status); + const y = topY + index * rowH; + const mid = y + nodeH / 2; + const next = (planZone.next_events || [])[0]; + const source = zoneControlSourceLabel(planZone.control_source); + const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined' + ? haSensorLabel(zone?.ha_entity_id || source) + : (planZone.device_name || tr('common.device')); + const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed); + const quietText = sim.quiet; + const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`; + const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort'); + const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents'); + + nodes.push(`
    ${esc(planZone.zone_name)}${esc(simulationLaneContext(planZone))}
    `); + nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'})); + nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)})); + nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge})); + const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : ''; + nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?tr('simulation.running'):tr('simulation.idle')),badgeClass:sim.demand?'active':''})); + nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'})); + + links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link')); + links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link')); + links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link')); + links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link')); + links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus')); + if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus')); + }); + + boardHost.style.width = `${boardW}px`; + boardHost.style.height = `${boardH}px`; + boardHost.innerHTML = `${nodes.join('')}`; + + const timelineItems = []; + if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event})); + zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name}))); + timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at)); + const unique = [], seen = new Set(); + for (const item of timelineItems) { + const key = `${item.scope}|${item.event.at}|${item.event.label}`; + if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break; + } + timelineHost.innerHTML = unique.length ? unique.map(item => { + const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`; + return `
    ${esc(item.scope)}

    ${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

    `; + }).join('') : `
    ${esc(tr('plan.noEvents'))}
    `; + + const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)); + rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
    ${esc(tr('common.automation'))}

    ${esc(rule.name)}

    ${esc(automationTriggerLabel(rule))}

    ${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}

    ${esc(simulationRuleAction(rule))}
    ${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
    `).join('') : `
    ${esc(tr('simulation.noRules'))}
    `; +} + +function scheduleControlPlanLoad() { + clearTimeout(app.controlPlanTimer); + app.controlPlanTimer = setTimeout(loadControlPlan, 180); +} + diff --git a/web/js/entities.js b/web/js/entities.js new file mode 100644 index 0000000..e6ff8aa --- /dev/null +++ b/web/js/entities.js @@ -0,0 +1,332 @@ +function deviceFeaturePanel(device) { + const features = [ + ['light', 'supports_light', tr('devices.light')], + ['xfan', 'supports_xfan', tr('devices.xfan')], + ['health', 'supports_health', tr('devices.health')], + ['air', 'supports_air', tr('devices.air')], + ['sleep', 'supports_sleep', tr('devices.sleep')], + ].filter(([, support]) => device[support] === true); + if (!features.length) return `
    ${esc(tr('devices.features'))}${esc(tr('devices.noExtraFeatures'))}
    `; + return `
    ${esc(tr('devices.features'))}
    ${features.map(([field,,label]) => ``).join('')}
    `; +} + +function deviceZoneDisabled(deviceId) { + return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false); +} + +function deviceCard(device, detailed = false) { + const modes = ['auto','cool','dry','fan','heat']; + const fans = [0,1,3,5]; + const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms)); + const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms'; + const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto'); + const networkInfo = `
    ${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
    `; + const zoneLocked = !detailed && deviceZoneDisabled(device.id); + const locked = zoneLocked ? ' disabled' : ''; + const quickClass = detailed ? '' : ' quick-control-card quick-device-control'; + const quickRow = detailed ? '' : ' quick-control-row'; + return `
    +
    +

    ${esc(device.name)}

    ${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

    + +
    +
    + +
    ${Number(device.target_temperature).toFixed(1)}°C
    + +
    +
    ${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
    + ${zoneLocked ? `
    ${esc(tr('devices.disabledZoneTechnicalOnly'))}
    ` : ''} +
    ${modes.map(mode => ``).join('')}
    +
    ${fans.map(fan => ``).join('')}
    +
    + + ${device.supports_quiet === false ? '' : ``} + ${device.supports_turbo === false ? '' : ``} +
    + ${detailed ? deviceFeaturePanel(device) : ''} + ${detailed ? `` : ''} +
    `; +} + +function renderDevices() { + const empty = `
    ${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
    `; + $('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty; + $('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty; + const dashboardCount = $('#dashboardDeviceCount'); + if (dashboardCount) dashboardCount.textContent = String(app.devices.length); +} + +function zoneStrategyLabel(zone) { + if (zone.sensor_source === 'combined') return tr('zones.combinedSource'); + if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource'); + return tr('zones.greeSource'); +} + +function zoneControlSourceLabel(source) { + return ({ + device: tr('zones.sourceDevice'), + external: tr('zones.sourceExternal'), + combined: tr('zones.sourceCombined'), + device_fallback: tr('zones.sourceFallback'), + device_discrepancy_fallback: tr('zones.sourceDiscrepancy'), + unavailable: tr('zones.sourceUnavailable'), + })[source] || source || tr('zones.sourceUnavailable'); +} + +function zonePresetLabel(preset) { + const key = `preset.${preset || 'comfort'}`; + return tr(key) === key ? (preset || 'comfort') : tr(key); +} + +function zoneControlOwnerLabel(zone) { + const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation')); + const source=zone.control_source || ''; + if(owner==='global_off') return tr('zones.ownerGlobalOff'); + if(owner==='direct_manual') { + if(source==='home_assistant_direct') return tr('zones.ownerHaDirect'); + if(source==='web_direct') return tr('zones.ownerWebDirect'); + return tr('zones.ownerExternal'); + } + if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat'); + return tr('zones.ownerAutomation'); +} + +function zoneControlOwnerMeta(zone) { + const parts=[]; + if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`); + if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`); + if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`); + return parts.join(' · '); +} + +function zoneRuntimeStatusLabel(zone, effectiveMode) { + if (zone.device_manual_override === true) return tr('zones.manualDeviceControl'); + if (!zone.enabled) return tr('common.disabled'); + if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff'); + if ((effectiveMode || 'off') === 'off') return tr('zones.waiting'); + if (zone.current_temperature == null) return tr('zones.noMeasurement'); + return zone.demand ? tr('zones.requesting') : tr('zones.satisfied'); +} + +function zoneCard(zone, detailed = true) { + const device = app.devices.find(d => d.id === zone.device_id); + const state = zone.enabled ? tr('common.active') : tr('common.disabled'); + const sensorDetails = zone.sensor_source === 'device' + ? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}` + : `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`; + const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature; + const target = Number(zone.effective_setpoint ?? zone.setpoint); + const manual = zone.manual_preset || 'auto'; + const displayPreset = zone.manual_preset || zone.active_preset || 'comfort'; + const mode = zone.inherit_house_mode ? 'house' : zone.mode; + const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off'; + const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode); + const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`; + const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null; + const deviceManualOverride = zone.device_manual_override === true; + const localThermostatPower = zone.local_thermostat_power; + const localThermostatResumeAt = zone.local_thermostat_resume_at || null; + const temporarySession = zone.temporary_quick_thermostat || null; + const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null; + const localThermostatOverride = localThermostatPower === true || localThermostatPower === false; + const override = deviceManualOverride + ? (zone.device_manual_override_until + ? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}) + : tr('zones.manualDeviceNoBoundary')) + : temporarySession + ? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'}) + : localThermostatOverride + ? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff') + : (zone.manual_override_until + ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` + : (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl'))); + const manualTakeover = deviceManualOverride + ? `
    ${esc(tr('zones.manualDeviceControl'))}

    ${esc(tr('zones.manualDeviceDescription'))}

    ` + : localThermostatOverride && !temporarySession + ? `
    ${esc(tr('zones.localThermostatControl'))}${localThermostatPower === false && localThermostatResumeAt + ? `

    ${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}

    ` + : `

    ${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}

    `}
    ` + : ''; + const localPowerDisabled = !device || device.enabled === false ? ' disabled' : ''; + const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power); + const localThermostatPowerControl = ``; + const temporaryThermostatControl = temporarySession + ? `` + : ``; + + if (detailed) { + const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name); + const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup'); + const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly'); + return `
    +
    ${esc(tr('zones.configuration'))}

    ${esc(zone.name)}

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

    +
    +
    ${esc(tr('zones.currentStatus'))}
    ${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
    +
    ${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
    +
    +
    +
    ${esc(tr('zones.modePolicy'))}${esc(policy)}
    +
    ${esc(tr('zones.source'))}${esc(zoneStrategyLabel(zone))}
    +
    ${esc(tr('zones.hysteresis'))}${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C
    +
    ${esc(tr('zones.smartFan'))}${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}
    +
    +
    ${esc(sensorDetails)}
    + ${manualTakeover} + +
    `; + } + + return `
    +

    ${esc(zone.name)}

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

    ${localThermostatPowerControl}${temporaryThermostatControl}
    +
    ${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
    ${Number.isFinite(target)?target.toFixed(1):'--'}°C
    ${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
    +
    + ${['auto','comfort','sleep','away'].map(preset=>``).join('')} +
    +
    +
    ${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(override)}
    +
    ${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${esc(zoneControlOwnerMeta(zone))}
    + ${manualTakeover} +
    `; +} + +function renderZones() { + const empty = `
    ${esc(tr('zones.emptyTitle'))}${esc(tr('zones.emptyText'))}
    `; + $('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty; + const dashboard = $('#dashboardZones'); + if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty; + const dashboardCount = $('#dashboardZoneCount'); + if (dashboardCount) dashboardCount.textContent = String(app.zones.length); +} + +function groupZones(group) { + const wanted = new Set(group?.zone_ids || []); + return app.zones.filter(zone => wanted.has(zone.id)); +} + +function groupState(group) { + const zones = groupZones(group); + const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))]; + const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))]; + return { + zones, + mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'), + preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'), + }; +} + +function groupCard(group, detailed = false) { + const state = groupState(group); + const memberNames = state.zones.map(zone => zone.name); + const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers'); + const powerEnabled = group.power_enabled !== false; + const masterOff = app.settings?.house_power_enabled === false; + const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value)); + const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value); + return `
    +
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(memberText)}

    + ${detailed ? `
    ${esc(tr('groups.mode'))}${esc(modeLabelForGroup(state.mode))}
    ${esc(tr('groups.profile'))}${esc(presetLabelForGroup(state.preset))}
    ${esc(tr('groups.members'))}${state.zones.length}
    ` : ''} +
    ${esc(tr('common.power'))}
    +
    ${esc(tr('groups.mode'))}
    ${['house','heat','cool'].map(mode => ``).join('')}
    +
    ${esc(tr('groups.profile'))}
    ${['auto','comfort','sleep','away'].map(preset => ``).join('')}
    + ${masterOff ? `

    ${esc(tr('groups.masterOff'))}

    ` : ''} + ${detailed ? `` : ''} +
    `; +} + +function renderGroups() { + const empty = `
    ${esc(tr('groups.emptyTitle'))}${esc(tr('groups.emptyText'))}
    `; + const dashboard = $('#dashboardGroups'); + if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty; + const dashboardCount = $('#dashboardGroupCount'); + if (dashboardCount) dashboardCount.textContent = String(app.groups.length); + const list = $('#groupList'); + if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty; +} + +async function sendGroupControl(id, patch) { + try { + const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); + if (result.group) { + const index = app.groups.findIndex(group => group.id === result.group.id); + if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group); + } + (result.zones || []).forEach(zone => { + const index = app.zones.findIndex(item => item.id === zone.id); + if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); + }); + (result.devices || []).forEach(updateDevice); + if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; + renderAll(); scheduleControlPlanLoad(); + const failed = Array.isArray(result.failed) ? result.failed.length : 0; + if (failed) toast(tr('groups.partial', {count: failed}), true); + else toast(tr('groups.controlUpdated')); + } catch (error) { toast(error.message, true); } +} + +function renderGroupZoneChoices(selectedIds = null) { + const host = $('#groupZoneChoices'); if (!host) return; + const selected = new Set(selectedIds || []); + host.innerHTML = app.zones.length ? app.zones.map(zone => { + const device = app.devices.find(item => item.id === zone.device_id); + return ``; + }).join('') : `
    ${esc(tr('groups.noZones'))}
    `; +} + +function populateGroup(id) { + const item = app.groups.find(group => group.id === id); if (!item) return; + const form = $('#groupForm'); form.reset(); + form.elements.id.value = item.id; + form.elements.name.value = item.name; + renderGroupZoneChoices(item.zone_ids || []); + openDialog('groupDialog'); +} + +function renderSchedules() { + const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`)); + $('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => { + const zone = app.zones.find(z => z.id === item.zone_id); + const days = item.weekdays.map(day => dayNames[day-1]).join(', '); + return `

    ${esc(item.name)}

    ${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}

    ${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}
    +
    ${esc(tr('common.from'))}${esc(item.start_time)}
    ${esc(tr('common.to'))}${esc(item.end_time)}
    ${esc(tr('schedules.profile'))}${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}
    +
    `; + }).join('') : `
    ${esc(tr('schedules.emptyTitle'))}${esc(tr('schedules.emptyText'))}
    `; +} + +function renderAutomations() { + $('#automationList').innerHTML = app.automations.length ? app.automations.map(item => { + const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null; + const actionDevice = app.devices.find(device => device.id === item.action_device_id); + const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice'); + const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—'); + const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—'; + return `

    ${esc(item.name)}

    ${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}

    ${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}
    +
    ${esc(tr('common.power'))}${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}
    ${esc(tr('common.mode'))}${esc(mode)}
    ${esc(tr('groups.profile'))}${esc(preset)}
    ${esc(tr('automations.last'))}${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}
    +
    `; + }).join('') : `
    ${esc(tr('automations.emptyTitle'))}${esc(tr('automations.emptyText'))}
    `; +} + +function fillSelects() { + const deviceOptions = app.devices.map(d => ``).join(''); + const zoneOptions = app.zones.map(z => ``).join(''); + const groupOptions = app.groups.map(g => ``).join(''); + ['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => { + const select = $(selector); if (!select) return; + const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current; + }); + const automationGroupSelect = $('#automationForm [name=action_group_id]'); + if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; } + const zoneSelect = $('#scheduleForm [name=zone_id]'); + if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; } + const presetZone = $('#schedulePresetZone'); + if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; } + const copyZone=$('#copyZoneSource'); + if(copyZone){ + const current=copyZone.value; + const editingZoneId=$('#zoneForm')?.dataset.editingZoneId || ''; + const sources=app.zones.filter(zone=>zone.id!==editingZoneId); + copyZone.innerHTML=``+sources.map(z=>``).join(''); + copyZone.value=[...copyZone.options].some(option=>option.value===current)?current:''; + } +} + diff --git a/web/js/events.js b/web/js/events.js new file mode 100644 index 0000000..7f92ca4 --- /dev/null +++ b/web/js/events.js @@ -0,0 +1,381 @@ + +document.addEventListener('click', async event => { + const button = event.target.closest('button'); if (!button) return; + if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; } + if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; } + if (button.dataset.dashboardTab) { setDashboardTab(button.dataset.dashboardTab); return; } + if (button.dataset.nav) { + if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav); + return; + } + if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; } + if (button.dataset.open) { + const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); + if (form) form.reset(); + if (button.dataset.open === 'zoneDialog') { + if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; } + updateZoneSensorFields(); + } + if (button.dataset.open === 'groupDialog') { + if (form?.elements.id) form.elements.id.value = ''; + renderGroupZoneChoices([]); + } + if (button.dataset.open === 'automationDialog') updateAutomationTargetFields(); + if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); + openDialog(button.dataset.open); + return; + } + if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; } + if (button.dataset.chartLegend !== undefined) { + const id=button.dataset.chartId; + const runtime=chartRuntime.get(id); + const index=Number(button.dataset.chartLegend); + const item=runtime?.series?.[index]; + if(item){ + setChartSeriesHidden(id,item,index,!isChartSeriesHidden(id,item,index)); + redrawHistoryChart(id); + } + return; + } + if (button.dataset.chartZoom) { + const id = button.dataset.chartId; + const current = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM); + const next = button.dataset.chartZoom === 'in' ? Math.min(MAX_CHART_ZOOM, current * 1.5) : button.dataset.chartZoom === 'out' ? Math.max(1, current / 1.5) : 1; + setChartZoom(id,next); + return; + } + if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; } + if (button.dataset.historyAction) { await handleHistoryAction(button); return; } + if (button.dataset.scheduleTemplate) { + const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true); + if (!confirm(tr('schedules.replaceConfirm'))) return; + try { + button.disabled = true; + await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}}); + await loadBootstrap(); toast(tr('schedules.templateApplied')); + } catch (error) { toast(error.message, true); } finally { button.disabled = false; } + return; + } + const action = button.dataset.action; if (!action) return; + const device = app.devices.find(v => v.id === button.dataset.device); + if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) { + return toast(tr('devices.disabledZoneTechnicalOnly'), true); + } + if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power}); + if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)}); + if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true}); + if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)}); + if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]}); + if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } + if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } + if (action === 'rename-device' && device) return populateDeviceRename(device.id); + if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); + if (action === 'house-mode') { + try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); } + catch(error){ toast(error.message,true); } return; + } + if (action === 'house-power') { + const power = button.dataset.value === 'true'; + if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return; + try { + button.disabled = true; + const result = await api('/api/house/power',{method:'POST',body:{power}}); + app.settings = result.settings || app.settings; + app.devices = result.devices || app.devices; + app.groups = result.groups || app.groups; + renderAll(); scheduleControlPlanLoad(); + const failed = Array.isArray(result.failed) ? result.failed.length : 0; + if (failed) toast(tr('house.powerPartial', {count: failed}), true); + else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone')); + } catch(error) { toast(error.message, true); } + finally { button.disabled = false; } + return; + } + if (action === 'house-preset') { + try { + const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); + app.settings=result.settings||app.settings; app.zones=result.zones||app.zones; app.devices=result.devices||app.devices; + renderAll(); scheduleControlPlanLoad(); + const failed=Array.isArray(result.failed)?result.failed.length:0; + if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated')); + } + catch(error){ toast(error.message,true); } return; + } + if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); + if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value}); + if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); + if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true'); + if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id); + if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; } + if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value}); + if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value}); + if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'}); + if (action === 'zone-resume-automation') { + const patch = button.dataset.resume === 'local' + ? {clear_local_thermostat_override:true} + : {clear_device_manual_override:true}; + return sendZoneControl(button.dataset.id, patch); + } + if (action === 'zone-go-control') { + showView('dashboard', {scroll:false}); + setDashboardTab('thermostats', {scroll:false}); + const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id); + requestAnimationFrame(() => target?.scrollIntoView({behavior:'smooth', block:'center'})); + return; + } + if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; } + if (action === 'edit-group') return populateGroup(button.dataset.id); + if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group'); + if (action === 'edit-zone') return populateZone(button.dataset.id); + if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone'); + if (action === 'edit-schedule') return populateSchedule(button.dataset.id); + if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule'); + if (action === 'edit-automation') return populateAutomation(button.dataset.id); + if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation'); + if (action === 'revoke-access-token') { + if (!confirm(tr('confirm.revokeToken'))) return; + try { + await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'}); + app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id); + renderAccessTokens(); + toast(tr('toast.tokenRevoked')); + } catch (error) { toast(error.message, true); } + return; + } +}); + +$('#refreshButton').addEventListener('click', () => { + const form = activeDirtySettingsForm(); + if (form && !confirmDiscardForm(form)) return; + loadBootstrap(true); +}); +$('#connectionRetry')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + if (app.ws) { try { app.ws.close(); } catch (_) {} app.ws = null; } + await loadBootstrap(false); + connectWebSocket(); + } finally { button.disabled = false; } +}); +$('#discoverButton').addEventListener('click', () => { + const form=$('#discoverForm'); form.reset(); + form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000))); + openDialog('discoverDialog'); +}); +$('#historyRefresh').addEventListener('click', loadHistory); +$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); }); +$('#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); +$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields); +$('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); }); +$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); }); +$('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); }); +$('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } }); +document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); }); + +$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60),alert_types:{stale_sensor:f.notification_alert_stale_sensor.checked,sensor_errors:f.notification_alert_sensor_errors.checked,communication:f.notification_alert_communication.checked,target_timeout:f.notification_alert_target_timeout.checked,automation:f.notification_alert_automation.checked,control_errors:f.notification_alert_control_errors.checked,important_events:f.notification_alert_important_events.checked,other:f.notification_alert_other.checked}}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast(tr('notifications.testSent'));}catch(e){toast(e.message,true);} }); + +$('#copyZoneSettings')?.addEventListener('click',()=>{ + const f=$('#zoneForm'); + const source=app.zones.find(zone=>zone.id===$('#copyZoneSource')?.value); + if(!source)return; + const targetId=f.dataset.editingZoneId || ''; + if(targetId && source.id===targetId){ toast(tr('zones.copyDifferent'),true); return; } + + // Identity and sensor assignment belong to the destination profile and must never + // be changed by copying thermostat tuning from another profile. + const protectedValues={ + id:f.elements.id.value, + name:f.elements.name.value, + device_id:f.elements.device_id.value, + sensor_source:f.elements.sensor_source.value, + ha_entity_id:f.elements.ha_entity_id.value, + enabled:f.elements.enabled.checked, + }; + const copyFields=['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','sensor_stale_after_seconds']; + copyFields.forEach(name=>{ + const input=f.elements[name]; + if(!input)return; + const value=name==='external_sensor_weight_percent'?(source.external_sensor_weight*100):source[name]; + if(value!==undefined&&value!==null)input.value=String(value); + }); + f.elements.smart_fan.checked=source.smart_fan!==false; + if(f.elements.mode_policy) f.elements.mode_policy.value=source.inherit_house_mode===false?(source.mode||'cool'):'house'; + + f.elements.id.value=protectedValues.id; + f.elements.name.value=protectedValues.name; + f.elements.device_id.value=protectedValues.device_id; + f.elements.sensor_source.value=protectedValues.sensor_source; + f.elements.ha_entity_id.value=protectedValues.ha_entity_id; + f.elements.enabled.checked=protectedValues.enabled; + f.dataset.editingZoneId=targetId; + updateZoneSensorFields(); + updateDirtyIndicator(f); + toast(tr('zones.copiedFrom',{name:source.name})); +}); + +$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:tr('automations.presetHot'),trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:tr('automations.presetCold'),trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:tr('automations.presetMorning'),trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:tr('automations.presetNightOff'),trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; }); + +$('#tokenForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + if (!validateForm(form) || form.dataset.submitting === 'true') return; + setFormBusy(form, true, 'auth.connecting'); + app.token = new FormData(form).get('token').trim(); + localStorage.setItem('gree_controller_token', app.token); + if (app.ws) app.ws.close(); + try { await loadBootstrap(); } + finally { setFormBusy(form, false); } +}); + +$('#discoverForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + await runFormTask(form, async () => { + const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}}); + form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []); + }, {busyKey:'actions.discovering'}); +}); + +$('#discoveryNamesForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + const inputs = $$('input[data-device-id]', form); + await runFormTask(form, async () => { + const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}}))); + updated.forEach(updateDevice); + form.closest('dialog').close(); + renderAll(); + toast(tr('common.saved')); + }); +}); + +$('#renameDeviceForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + await runFormTask(form, async () => { + const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}}); + updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); + }); +}); + +$('#deviceForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form)); + data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked; + await runFormTask(form, async () => { + await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added')); + }); +}); + +$('#zoneForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const id=form.dataset.editingZoneId || raw.id; const body={ + name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked, + mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint), + cool_comfort_setpoint:parseDecimal(raw.cool_comfort_setpoint),cool_sleep_setpoint:parseDecimal(raw.cool_sleep_setpoint),cool_away_setpoint:parseDecimal(raw.cool_away_setpoint), + heat_comfort_setpoint:parseDecimal(raw.heat_comfort_setpoint),heat_sleep_setpoint:parseDecimal(raw.heat_sleep_setpoint),heat_away_setpoint:parseDecimal(raw.heat_away_setpoint), + hysteresis:parseDecimal(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds), + min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked, + sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference),sensor_stale_after_seconds:Number(raw.sensor_stale_after_seconds||300),revision:raw.revision?Number(raw.revision):null + }; + await runFormTask(form, async () => { + await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); +}); + +$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields); +$('#temporaryStartKind')?.addEventListener('change', updateTemporaryThermostatFields); +$$('[data-temporary-start-delay]').forEach(button => button.addEventListener('click', () => { + const form = $('#temporaryThermostatForm'); + form.start_delay_minutes.value = button.dataset.temporaryStartDelay; +})); +$$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => { + const form = $('#temporaryThermostatForm'); + form.duration_minutes.value = button.dataset.temporaryDuration; +})); + +$('#temporaryThermostatStop')?.addEventListener('click', async event => { + const id = event.currentTarget.dataset.id; if (!id) return; + try { + event.currentTarget.disabled = true; + const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{clear_temporary_quick_thermostat:true}}); + const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; + $('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped')); + } catch (error) { toast(error.message, true); } + finally { event.currentTarget.disabled = false; } +}); + +$('#temporaryThermostatForm')?.addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); + const activeSession = form.dataset.activeSession === 'true'; + const zone = app.zones.find(item => item.id === raw.zone_id); + const existingSession = zone?.temporary_quick_thermostat; + const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind; + const request = {start_kind:startKind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)}; + if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes); + if (!activeSession && startKind === 'at') { + const startAt = new Date(raw.start_at); + if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true); + request.start_at = startAt.toISOString(); + } + if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes); + if (raw.finish_kind === 'until') { + const until = new Date(raw.until); + if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true); + const effectiveStart = activeSession ? new Date() + : startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000) + : startKind === 'at' ? new Date(raw.start_at) : new Date(); + if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true); + request.until = until.toISOString(); + } + if (['temperature_reached','temperature_stable'].includes(raw.finish_kind)) { + request.temperature_operator = raw.temperature_operator; + request.tolerance_c = parseDecimal(raw.tolerance_c); + if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes); + const safety = Number(raw.max_duration_minutes); + if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety; + } + await runFormTask(form, async () => { + const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); + const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone; + form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); + }, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'}); +}); + +$('#groupForm')?.addEventListener('submit', async event => { + event.preventDefault(); + const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const id=raw.id || ''; + const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value); + if (!zone_ids.length) return toast(tr('groups.chooseMember'), true); + const existing=id ? app.groups.find(group => group.id===id) : null; + const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false}; + await runFormTask(form, async () => { + await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body}); + form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); +}); + +$('#scheduleForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value)); + const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:parseDecimal(raw.setpoint||23)}; + await runFormTask(form, async () => { + await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); +}); + +$('#automationForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id; + const groupTarget=raw.action_target_kind==='group'; + const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature); + const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)}; + await runFormTask(form, async () => { + await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); +}); diff --git a/web/js/forms.js b/web/js/forms.js new file mode 100644 index 0000000..d3251f2 --- /dev/null +++ b/web/js/forms.js @@ -0,0 +1,302 @@ +function toast(message, error = false) { + const host = $('#toastStack'); if (!host) return; + const item = document.createElement('div'); + item.className = `toast-item ${error ? 'error' : 'success'}`; + item.innerHTML = `${error ? '!' : '✓'}
    ${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
    `; + host.appendChild(item); + requestAnimationFrame(() => item.classList.add('show')); + const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); }; + item.querySelector('.toast-close').addEventListener('click', remove); + item._timer = setTimeout(remove, error ? 5200 : 3600); +} + +const cleanFormSnapshots = new WeakMap(); + +function formSnapshot(form) { + if (!form) return ''; + const rows = []; + $$('input, select, textarea', form).forEach((field, index) => { + if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return; + const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`; + const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? ''); + rows.push([key, value, String(field.disabled)]); + }); + return JSON.stringify(rows); +} + +function markFormClean(form) { + if (!form) return; + cleanFormSnapshots.set(form, formSnapshot(form)); + form.classList.remove('has-unsaved-changes'); +} + +function updateDirtyIndicator(form) { + if (!form || !cleanFormSnapshots.has(form)) return; + form.classList.toggle('has-unsaved-changes', isFormDirty(form)); +} + +function isFormDirty(form) { + if (!form || !cleanFormSnapshots.has(form)) return false; + return cleanFormSnapshots.get(form) !== formSnapshot(form); +} + +function clearFormErrors(form) { + if (!form) return; + $$('.field-error, .form-error-summary', form).forEach(node => node.remove()); + $$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid')); + $$('.has-error', form).forEach(node => node.classList.remove('has-error')); +} + +function showFieldError(field, message) { + if (!field || !message) return; + field.setAttribute('aria-invalid', 'true'); + const label = field.closest('label'); + if (label) label.classList.add('has-error'); + const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null; + if (existing) { existing.textContent = message; return; } + const note = document.createElement('small'); + note.className = 'field-error'; + note.textContent = message; + field.insertAdjacentElement('afterend', note); +} + +function showFormError(form, message) { + if (!form || !message) return; + const summary = document.createElement('div'); + summary.className = 'form-error-summary'; + summary.setAttribute('role', 'alert'); + summary.textContent = message; + const anchor = $('.settings-save-bar', form) || $('.form-actions', form); + if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor); + else form.appendChild(summary); +} + +function validationMessageFor(field) { + if (field.validity?.valueMissing) return tr('validation.required'); + if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid'); + if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) { + return tr('validation.range', {min:field.min || '—', max:field.max || '—'}); + } + if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid'); + return field.validity?.valid === false ? tr('validation.invalid') : ''; +} + +function httpUrlValid(value) { + if (!String(value || '').trim()) return true; + try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; } +} + +function entityIdValid(value) { + return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim()); +} + +function validateDecimalField(form, name, min, max) { + const field = form?.elements?.[name]; + if (!field || field.disabled) return true; + const value = parseDecimal(field.value); + if (!Number.isFinite(value) || value < min || value > max) { + showFieldError(field, tr('validation.range', {min, max})); + return false; + } + return true; +} + +function validateForm(form) { + if (!form) return true; + clearFormErrors(form); + let valid = true; + $$('input, select, textarea', form).forEach(field => { + if (field.disabled || field.type === 'hidden' || field.type === 'file') return; + const message = validationMessageFor(field); + if (message) { showFieldError(field, message); valid = false; } + }); + + if (form.id === 'homeAssistantForm') { + const url = form.elements.ha_url; + if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } + ['ha_entity_id','ha_outdoor_entity_id'].forEach(name => { + const field = form.elements[name]; + if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; } + }); + } + + if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) { + const url = form.elements.influx_url; + if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; } + else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } + } + + if (form.id === 'zoneForm') { + const source = form.elements.sensor_source?.value; + const entity = form.elements.ha_entity_id; + if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) { + showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required')); + valid = false; + } + [ + ['cool_comfort_setpoint',8,30],['cool_sleep_setpoint',8,30],['cool_away_setpoint',8,30], + ['heat_comfort_setpoint',8,30],['heat_sleep_setpoint',8,30],['heat_away_setpoint',8,30], + ['hysteresis',0.1,5],['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8] + ].forEach(([name,min,max]) => { if (!validateDecimalField(form,name,min,max)) valid = false; }); + } + + if (!valid) { + showFormError(form, tr('validation.fixFields')); + const first = $('[aria-invalid="true"]', form); + const pane = first?.closest?.('[data-settings-pane]'); + if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); + first?.focus({preventScroll:true}); + first?.scrollIntoView({behavior:'smooth', block:'center'}); + } + return valid; +} + +function apiErrorField(form, message) { + const text = String(message || '').toLowerCase(); + const candidates = []; + if (text.includes('influx')) candidates.push('influx_url'); + if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url'); + if (text.includes('entity')) candidates.push('ha_entity_id','ha_outdoor_entity_id'); + if (text.includes('device')) candidates.push('device_id','action_device_id','trigger_device_id'); + if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature','cool_comfort_setpoint','action_target_temperature'); + if (text.includes('name')) candidates.push('name','controller_id'); + if (text.includes('token')) candidates.push('ha_token','influx_token','pushover_app_token'); + if (text.includes('url')) candidates.push('ha_url','influx_url','slack_webhook_url','discord_webhook_url'); + return candidates.map(name => form?.elements?.[name]).find(Boolean) || null; +} + +function presentFormError(form, error) { + clearFormErrors(form); + const message = error?.message || tr('validation.invalid'); + const field = apiErrorField(form, message); + if (field) showFieldError(field, message); + showFormError(form, message); + if (field) { + const pane = field.closest?.('[data-settings-pane]'); + if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); + field.focus({preventScroll:true}); + field.scrollIntoView({behavior:'smooth', block:'center'}); + } + toast(message, true); +} + +function setFormBusy(form, busy, busyKey = 'actions.saving') { + if (!form) return; + const buttons = $$('button[type="submit"]', form); + if (busy) { + form.dataset.submitting = 'true'; + form.setAttribute('aria-busy', 'true'); + form.classList.add('is-saving'); + buttons.forEach(button => { + if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = `${esc(tr(busyKey))}`; + }); + } else { + delete form.dataset.submitting; + form.removeAttribute('aria-busy'); + form.classList.remove('is-saving'); + buttons.forEach(button => { + button.disabled = false; + if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; } + }); + } +} + +async function runFormTask(form, task, {busyKey='actions.saving', validate=true}={}) { + if (!form || form.dataset.submitting === 'true') return {ok:false, duplicate:true}; + if (validate && !validateForm(form)) return {ok:false, validation:true}; + clearFormErrors(form); + setFormBusy(form, true, busyKey); + try { + const value = await task(); + markFormClean(form); + return {ok:true, value}; + } catch (error) { + presentFormError(form, error); + return {ok:false, error}; + } finally { + setFormBusy(form, false); + } +} + +function restoreTrackedForm(form) { + if (!form) return; + if (form.id === 'settingsForm') renderSettings(); + else if (form.id === 'nightModeForm') renderNightSettings(); + else if (form.id === 'homeAssistantForm') { + app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; + renderHomeAssistantSettings(); + } else markFormClean(form); +} + +function confirmDiscardForm(form) { + if (!isFormDirty(form)) return true; + if (!confirm(tr('confirm.discardChanges'))) return false; + restoreTrackedForm(form); + return true; +} + +function activeDirtySettingsForm() { + const active = $('.view.active'); + if (!active) return null; + const form = $('form.settings-form', active); + return form && isFormDirty(form) ? form : null; +} + +function canLeaveCurrentView(nextName) { + if (nextName === app.currentView) return true; + const form = activeDirtySettingsForm(); + return !form || confirmDiscardForm(form); +} + +function requestDialogClose(dialog) { + if (!dialog) return true; + const form = $('form', dialog); + if (form && !confirmDiscardForm(form)) return false; + dialog.close(); + return true; +} + +function setupFormUx() { + $$('form').forEach(form => { + form.noValidate = true; + if (!cleanFormSnapshots.has(form)) markFormClean(form); + }); +} + +document.addEventListener('input', event => { + const field = event.target.closest?.('input, select, textarea'); + const form = field?.form; + if (!field || !form) return; + if (field.getAttribute('aria-invalid') === 'true') { + field.removeAttribute('aria-invalid'); + field.closest('label')?.classList.remove('has-error'); + if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove(); + $('.form-error-summary', form)?.remove(); + } + updateDirtyIndicator(form); +}); +document.addEventListener('change', event => updateDirtyIndicator(event.target?.form)); + +window.addEventListener('beforeunload', event => { + const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form)); + if (!dirty) return; + event.preventDefault(); + event.returnValue = ''; +}); + +document.addEventListener('cancel', event => { + const dialog = event.target.closest?.('dialog'); + if (!dialog) return; + const form = $('form', dialog); + if (!form || !isFormDirty(form)) return; + event.preventDefault(); + if (confirmDiscardForm(form)) dialog.close(); +}, true); + +function showTokenDialog() { + const dialog = $('#tokenDialog'); + if (!dialog.open) dialog.showModal(); +} + diff --git a/web/js/history.js b/web/js/history.js new file mode 100644 index 0000000..e15839f --- /dev/null +++ b/web/js/history.js @@ -0,0 +1,198 @@ +function renderHistorySummary() { + const host=$('#historySummary'); if(!host) return; + const deviceRows=app.historyData.devices, zoneRows=app.historyData.zones, sensorRows=app.historyData.sensors; + const cards=[ + [tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')], + [tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')], + [tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')], + ]; + host.innerHTML=cards.map(([label,value,detail])=>`
    ${esc(label)}${esc(value)}${esc(detail)}
    `).join(''); +} + +function renderOverviewHistory() { + const host=$('#historyCharts'); + host.innerHTML = historyChartMarkup('overviewIndoorChart',tr('history.allGreeIndoor'),tr('history.allGreeIndoorHint')) + + historyChartMarkup('overviewOutdoorChart',tr('history.allOutdoor'),tr('history.allOutdoorHint')) + + historyChartMarkup('overviewZonesChart',tr('history.allZoneControl'),tr('history.allZoneControlHint')); + + const deviceRows=app.historyData.devices; + const indoorSeries=app.devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.indoor_temperature):NaN})); + drawLineChart($('#overviewIndoorChart'),indoorSeries,deviceRows,{height:360}); renderLegend($('#overviewIndoorChartLegend'),indoorSeries); + + const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN})); + const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))]; + const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(entity)}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN})); + const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')]; + const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries]; + drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries); + + const zoneRows=app.historyData.zones; + const zoneSeries=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN})); + drawLineChart($('#overviewZonesChart'),zoneSeries,zoneRows,{height:340}); renderLegend($('#overviewZonesChartLegend'),zoneSeries); +} + +function renderZoneHistory() { + const selected=app.historyZone; + const rows=selected==='all'?app.historyData.zones:app.historyData.zones.filter(row=>row.zone_id===selected); + const host=$('#historyCharts'); + host.innerHTML=historyChartMarkup('zoneTemperatureChart',tr('history.temperatureOverview'),tr('history.temperatureOverviewHint'))+historyChartMarkup('zoneOperationChart',tr('history.operationOverview'),tr('history.operationOverviewHint'),true); + if(selected==='all'){ + const series=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN})); + drawLineChart($('#zoneTemperatureChart'),series,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),series); + const targets=app.zones.map((zone,index)=>({label:`${zone.name} · ${tr('history.targetShort')}`,color:historySeriesColor(index),dash:[5,4],value:row=>row.zone_id===zone.id?historyNumber(row.target_temperature):NaN})); + drawLineChart($('#zoneOperationChart'),targets,rows,{height:260}); renderLegend($('#zoneOperationChartLegend'),targets); + return; + } + const temperatureSeries=[ + {label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:row=>historyNumber(row.gree_temperature)}, + {label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:row=>historyNumber(row.external_temperature)}, + {label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:row=>historyNumber(row.control_temperature)}, + {label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:row=>historyNumber(row.target_temperature)}, + {label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:row=>historyNumber(row.device_setpoint)}, + {label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:row=>historyNumber(row.outdoor_temperature)}, + ]; + drawLineChart($('#zoneTemperatureChart'),temperatureSeries,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),temperatureSeries); + const operationSeries=[ + {label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed),tooltipValue:value=>fanLabel(Math.round(value))}, + {label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5,tooltipValue:(_,row)=>row.demand?tr('common.active'):tr('common.disabled')}, + {label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5,tooltipValue:(_,row)=>row.power?tr('common.on'):tr('common.off')}, + ]; + drawLineChart($('#zoneOperationChart'),operationSeries,rows,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#zoneOperationChartLegend'),operationSeries); +} + +function renderDeviceHistory() { + const selected=app.historyDevice; + const rows=selected==='all'?app.historyData.devices:app.historyData.devices.filter(row=>row.device_id===selected); + const host=$('#historyCharts'); + host.innerHTML=historyChartMarkup('deviceIndoorChart',tr('history.deviceIndoor'),tr('history.deviceIndoorHint'))+historyChartMarkup('deviceOutdoorChart',tr('history.deviceOutdoor'),tr('history.deviceOutdoorHint'))+historyChartMarkup('deviceTargetChart',tr('history.deviceTargets'),tr('history.deviceTargetsHint'),true); + const devices=selected==='all'?app.devices:app.devices.filter(device=>device.id===selected); + const indoor=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.indoor_temperature):NaN})); + const outdoor=devices.filter(device=>rows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN})); + const targets=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),dash:[6,4],value:row=>row.device_id===device.id?historyNumber(row.target_temperature):NaN})); + drawLineChart($('#deviceIndoorChart'),indoor,rows,{height:350}); renderLegend($('#deviceIndoorChartLegend'),indoor); + drawLineChart($('#deviceOutdoorChart'),outdoor,rows,{height:310}); renderLegend($('#deviceOutdoorChartLegend'),outdoor); + drawLineChart($('#deviceTargetChart'),targets,rows,{height:260}); renderLegend($('#deviceTargetChartLegend'),targets); +} + +function renderSensorHistory() { + const selected=app.historySensor; + const rows=selected==='all'?app.historyData.sensors:app.historyData.sensors.filter(row=>row.entity_id===selected); + const entities=[...new Set(rows.map(row=>row.entity_id))]; + const host=$('#historyCharts'); + host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint')); + const series=entities.map((entity,index)=>({label:haSensorLabel(entity),color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN})); + drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series); +} + +function customSeriesOptions() { + const items=[]; + app.devices.forEach(device=>{ + items.push([`device|${device.id}|indoor`,`${device.name} · ${tr('history.indoorTemperature')}`]); + items.push([`device|${device.id}|outdoor`,`${device.name} · ${tr('history.greeOutdoor')}`]); + items.push([`device|${device.id}|target`,`${device.name} · ${tr('history.deviceTarget')}`]); + }); + app.zones.forEach(zone=>{ + items.push([`zone|${zone.id}|control`,`${zone.name} · ${tr('history.controlTemperature')}`]); + items.push([`zone|${zone.id}|gree`,`${zone.name} · ${tr('history.greeSensor')}`]); + items.push([`zone|${zone.id}|external`,`${zone.name} · ${tr('history.roomSensor')}`]); + items.push([`zone|${zone.id}|target`,`${zone.name} · ${tr('history.comfortTarget')}`]); + items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]); + items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]); + }); + [...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`])); + return items; +} + +function customSeriesDefinition(key,index=0) { + const [kind,id,field]=String(key).split('|'); + const color=historySeriesColor(index); + if(kind==='device'){ + const device=app.devices.find(item=>item.id===id); if(!device) return null; + const labels={indoor:tr('history.indoorTemperature'),outdoor:tr('history.greeOutdoor'),target:tr('history.deviceTarget')}; + const fields={indoor:'indoor_temperature',outdoor:'outdoor_temperature',target:'target_temperature'}; + return {key,label:`${device.name} · ${labels[field]||field}`,color,dash:field==='target'?[6,4]:[],value:row=>!row.zone_id&&!row.entity_id&&row.device_id===id?historyNumber(row[fields[field]]):NaN}; + } + if(kind==='zone'){ + const zone=app.zones.find(item=>item.id===id); if(!zone) return null; + const fields={control:['control_temperature',tr('history.controlTemperature')],gree:['gree_temperature',tr('history.greeSensor')],external:['external_temperature',tr('history.roomSensor')],target:['target_temperature',tr('history.comfortTarget')],device_target:['device_setpoint',tr('history.deviceSetpoint')],outdoor:['outdoor_temperature',tr('history.outdoorTemperature')]}; + const info=fields[field]; if(!info) return null; + return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN}; + } + if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN}; + return null; +} + +function persistSavedCharts() { + localStorage.setItem('gree_controller_saved_charts',JSON.stringify(app.savedCharts.slice(0,30))); +} + +function renderCustomBuilder() { + const host=$('#historyCustomBuilder'); if(!host) return; + if(app.historyTab!=='custom'){host.innerHTML='';host.classList.remove('active');return;} + host.classList.add('active'); + const options=customSeriesOptions(); + const selected=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); + host.innerHTML=`

    ${esc(tr('history.customTitle'))}

    ${esc(tr('history.customDescription'))}

    +
    +
    ${selected.length?selected.map((item,index)=>`${esc(item.label)}`).join(''):`${esc(tr('history.noCustomSeries'))}`}
    +
    +
    ${app.savedCharts.length?app.savedCharts.map(item=>`
    `).join(''):`${esc(tr('history.noSavedCharts'))}`}
    +
    `; +} + +function renderCustomHistory() { + renderCustomBuilder(); + const host=$('#historyCharts'); + host.innerHTML=historyChartMarkup('customHistoryChart',tr('history.customChart'),tr('history.customChartHint')); + const series=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); + const rows=[...app.historyData.devices,...app.historyData.zones,...app.historyData.sensors]; + drawLineChart($('#customHistoryChart'),series,rows,{height:390}); renderLegend($('#customHistoryChartLegend'),series); +} + +function renderHistoryPage() { + renderHistorySummary(); + renderCustomBuilder(); + if(app.historyTab==='overview') renderOverviewHistory(); + else if(app.historyTab==='zones') renderZoneHistory(); + else if(app.historyTab==='devices') renderDeviceHistory(); + else if(app.historyTab==='sensors') renderSensorHistory(); + else renderCustomHistory(); +} + +function drawCurrentChartIfVisible() { + if (app.currentView === 'history') renderHistoryPage(); +} + +async function handleHistoryAction(button) { + const action=button.dataset.historyAction; + if(action==='add-series'){ + const value=$('#customSeriesSelect')?.value; + if(value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value); + renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; + } + if(action==='remove-series'){ + app.customChartSeries.splice(Number(button.dataset.index),1); + renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; + } + if(action==='save-chart'){ + if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); + const name=$('#customChartName')?.value.trim() || tr('history.customChart'); + const item={id:`chart-${Date.now()}`,name,series:[...app.customChartSeries],hours:$('#historyHours')?.value||'24'}; + app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return; + } + if(action==='load-chart'){ + const item=app.savedCharts.find(entry=>entry.id===button.dataset.id); if(!item) return; + app.customChartSeries=[...item.series]; if($('#historyHours')&&item.hours) $('#historyHours').value=String(item.hours); + renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return; + } + if(action==='delete-chart'){ + app.savedCharts=app.savedCharts.filter(entry=>entry.id!==button.dataset.id); persistSavedCharts(); renderCustomHistory(); return; + } + if(action==='copy-chart-link'){ + if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); + const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${APP_BASE}${path}`; + try { await navigator.clipboard.writeText(link); } catch (_) { const area=document.createElement('textarea'); area.value=link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); } + toast(tr('history.linkCopied')); return; + } +} + diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..5778b83 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,20 @@ +let historyResizeTimer; +window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } }); +window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); }); +if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register(withBase('/sw.js')).catch(()=>{})); + +async function startApplication() { + observeTopbarHeight(); + applyTheme(); + await loadLanguages(); + applyTranslations(); + setupFormUx(); + updateZoneSensorFields(); + updateSchedulePresetField(); + await loadBootstrap(); + initRouter(); +} + +setInterval(updateLocalThermostatCountdowns, 1000); +setInterval(updateTemporaryThermostatCountdowns, 1000); +startApplication().catch(error => console.error('Application startup failed:', error)); diff --git a/web/js/navigation.js b/web/js/navigation.js new file mode 100644 index 0000000..755c940 --- /dev/null +++ b/web/js/navigation.js @@ -0,0 +1,288 @@ +function setDashboardTab(tab, {scroll=true}={}) { + const next = ['main','thermostats','manual'].includes(tab) ? tab : 'main'; + app.dashboardTab = next; + $$('[data-dashboard-tab]').forEach(button => { + const active = button.dataset.dashboardTab === next; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + $$('[data-dashboard-panel]').forEach(panel => { + const active = panel.dataset.dashboardPanel === next; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + if (scroll && app.currentView === 'dashboard') window.scrollTo({top:0, behavior:'smooth'}); +} + +function showView(name, {push=true, scroll=true}={}) { + if (!canLeaveCurrentView(name)) return false; + app.currentView = name; + $$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name)); + $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name)))); + if (push) updateBrowserUrl(pathForView(name)); + if (scroll) window.scrollTo({top: 0, behavior: 'smooth'}); + if (name === 'dashboard') setDashboardTab(app.dashboardTab, {scroll:false}); + if (name === 'history') { renderHistoryNavigation(); loadHistory(); } + if (name === 'logs') loadLogs(); + return true; +} + +function showHistoryTab(tab, {push=true, load=true}={}) { + app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview'; + renderHistoryNavigation(); + if (push) updateBrowserUrl(currentHistoryPath()); + if (load) loadHistory(); +} + +async function sendDeviceCommand(id, command) { + try { + const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); + updateDevice(device); renderAll(); + } catch (error) { toast(error.message, true); } +} + +function updateDevice(device) { + const index = app.devices.findIndex(item => item.id === device.id); + if (index >= 0) app.devices[index] = device; else app.devices.push(device); +} + +async function sendZoneLocalPower(id, power) { + try { + const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}}); + const index = app.zones.findIndex(item => item.id === zone.id); + if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); + renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated')); + } catch (error) { toast(error.message, true); } +} + +async function sendZoneControl(id, patch) { + const sequence = (app.zoneControlSeq[id] || 0) + 1; + app.zoneControlSeq[id] = sequence; + try { + const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); + if (app.zoneControlSeq[id] !== sequence) return; + const index = app.zones.findIndex(item => item.id === zone.id); + if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); + renderSummary(); renderZones(); scheduleControlPlanLoad(); + } catch (error) { + if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } + } +} + +function queueZoneTemperature(zone, value) { + const next = Math.round(clamp(value, 8, 30) * 2) / 2; + zone.manual_setpoint = next; + zone.setpoint = next; + zone.effective_setpoint = next; + renderZones(); + clearTimeout(app.zoneTemperatureTimers[zone.id]); + app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160); +} + +function showDiscoveryNames(ids) { + const wanted = new Set(Array.isArray(ids) ? ids : []); + const devices = app.devices.filter(device => wanted.has(device.id)); + if (!devices.length) return; + const list = $('#discoveryNamesList'); + list.innerHTML = devices.map(device => ` + `).join(''); + openDialog('discoveryNamesDialog'); +} + +function populateDeviceRename(id) { + const device = app.devices.find(item => item.id === id); if (!device) return; + const form = $('#renameDeviceForm'); form.reset(); + form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0); + openDialog('renameDeviceDialog'); +} + +async function deleteEntity(type, id, labelKey) { + if (!confirm(tr('confirm.delete', {label: tr(labelKey)}))) return; + try { await api(`/api/${type}/${encodeURIComponent(id)}`, {method:'DELETE'}); await loadBootstrap(); toast(tr('common.removed')); } + catch (error) { toast(error.message, true); } +} + +function openDialog(id) { + fillSelects(); + const dialog = document.getElementById(id); + if (dialog && !dialog.open) { + const form = $('form', dialog); + if (form) { clearFormErrors(form); markFormClean(form); } + dialog.showModal(); + } +} + +function updateZoneSensorFields() { + const form = $('#zoneForm'); + if (!form) return; + const external = form.sensor_source.value !== 'device'; + $('#externalSensorFields').hidden = !external; + form.ha_entity_id.required = external; +} + +function updateSchedulePresetField() { + const form = $('#scheduleForm'); if (!form) return; + $('#scheduleSetpointField').hidden = form.preset.value !== 'custom'; + form.setpoint.required = form.preset.value === 'custom'; +} + +function updateAutomationTargetFields() { + const form = $('#automationForm'); if (!form) return; + const groupTarget = form.action_target_kind.value === 'group'; + $('#automationDeviceTarget').hidden = groupTarget; + $('#automationGroupTarget').hidden = !groupTarget; + $('#automationGroupPreset').hidden = !groupTarget; + $('#automationTargetTemperature').hidden = groupTarget; + $('#automationGroupHint').hidden = !groupTarget; + form.action_device_id.required = !groupTarget; + form.action_group_id.required = groupTarget; + form.action_target_temperature.disabled = groupTarget; + if (groupTarget) form.action_target_temperature.value = ''; + [...form.action_mode.options].forEach(option => { + if (!['dry','fan'].includes(option.value)) return; + option.disabled = groupTarget; + option.hidden = groupTarget; + }); + if (groupTarget && ['dry','fan'].includes(form.action_mode.value)) form.action_mode.value = ''; +} + +function populateZone(id) { + const item = app.zones.find(v => v.id === id); if (!item) return; + const form = $('#zoneForm'); + form.reset(); + form.dataset.editingZoneId = item.id; + form.elements.id.value = item.id; + fillSelects(); + Object.entries(item).forEach(([key,value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; }); + form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool'); + if (Number(item.profile_version || 0) === 0) { + if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21); + else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23); + } + form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100); + form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3); + form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120); + form.standby_offset_c.value = Number(item.standby_offset_c ?? 2); + form.smart_fan.checked = item.smart_fan !== false; + form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog'); +} + +const dateTimeLocalValue = value => { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return ''; + const pad = number => String(number).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +function updateTemporaryThermostatFields() { + const form = $('#temporaryThermostatForm'); if (!form) return; + const startKind = form.start_kind.value; + const kind = form.finish_kind.value; + const startDelay = $('#temporaryStartDelayFields'); + const startAt = $('#temporaryStartAtFields'); + const duration = $('#temporaryDurationFields'); + const until = $('#temporaryUntilFields'); + const temperature = $('#temporaryTemperatureFields'); + const schedule = $('#temporaryScheduleFields'); + startDelay.hidden = startKind !== 'delay'; + startAt.hidden = startKind !== 'at'; + duration.hidden = kind !== 'duration'; + until.hidden = kind !== 'until'; + temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind); + schedule.hidden = kind !== 'schedule_boundary'; + $('#temporaryHoldField').hidden = kind !== 'temperature_stable'; + form.start_delay_minutes.required = startKind === 'delay'; + form.start_at.required = startKind === 'at'; + form.duration_minutes.required = kind === 'duration'; + form.until.required = kind === 'until'; + form.hold_minutes.required = kind === 'temperature_stable'; +} + +function populateTemporaryThermostat(id) { + const zone = app.zones.find(item => item.id === id); if (!zone) return; + const form = $('#temporaryThermostatForm'); + form.reset(); + form.elements.zone_id.value = zone.id; + form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1); + form.start_kind.value = 'now'; + form.start_delay_minutes.value = '30'; + form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); + form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)); + const session = zone.temporary_quick_thermostat; + const activeSession = !!session?.activated_at; + form.dataset.activeSession = activeSession ? 'true' : 'false'; + if (session) { + form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now'); + if (form.start_kind.value === 'delay' && session.started_at) { + form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000)); + } + if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at); + form.finish_kind.value = session.finish_kind || 'duration'; + if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1); + if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator; + form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1); + if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60)); + if (session.finish_kind === 'duration') { + if (session.duration_seconds) { + form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60)); + } else if (session.expires_at) { + const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); + form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000)); + } + } + if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at); + if (session.safety_duration_seconds) { + form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60)); + } else if (session.safety_expires_at) { + const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); + form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000)); + } else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) { + form.max_duration_minutes.value = ''; + } + } + + // Once ownership has started, the historical start is informational. Editing changes only + // target/finish rules; rescheduling requires stopping the session and creating a new one. + form.start_kind.disabled = activeSession; + form.start_delay_minutes.disabled = activeSession; + form.start_at.disabled = activeSession; + $$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; }); + + updateTemporaryThermostatFields(); + const active = $('#temporaryThermostatActive'); + active.hidden = !session; + $('#temporaryThermostatStop').dataset.id = zone.id; + $('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart'); + updateTemporaryThermostatCountdowns(); + openDialog('temporaryThermostatDialog'); + updateTemporaryThermostatCountdowns(); +} + +function populateSchedule(id) { + const item = app.schedules.find(v => v.id === id); if (!item) return; + const form = $('#scheduleForm'); form.reset(); fillSelects(); + ['id','name','zone_id','start_time','end_time','setpoint','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); + form.enabled.checked = item.enabled; + $$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value))); + updateSchedulePresetField(); openDialog('scheduleDialog'); +} + +function populateAutomation(id) { + const item = app.automations.find(v => v.id === id); if (!item) return; + const form = $('#automationForm'); form.reset(); fillSelects(); + ['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); + const groupTarget = !!item.action_group_id; + form.action_target_kind.value = groupTarget ? 'group' : 'device'; + if (groupTarget) form.action_group_id.value = item.action_group_id; + form.action_power.value = item.action.power == null ? '' : String(item.action.power); + form.action_mode.value = item.action.mode || ''; + form.action_preset.value = item.action_preset || ''; + form.action_target_temperature.value = item.action.target_temperature ?? ''; + form.enabled.checked = item.enabled; + updateAutomationTargetFields(); + openDialog('automationDialog'); +} + diff --git a/web/js/realtime.js b/web/js/realtime.js new file mode 100644 index 0000000..9e66253 --- /dev/null +++ b/web/js/realtime.js @@ -0,0 +1,62 @@ +async function loadLogs() { + renderLogRetention(); + try { + const data = await api('/api/events?limit=150'); + const level=$('#logLevelFilter')?.value||'all', category=$('#logCategoryFilter')?.value||'all'; + const logs = (data.events || []).filter(item => (level==='all'||item.level===level) && (category==='all'||logCategory(item.kind)===category)); + $('#logList').innerHTML = logs.length + ? 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); } +} + +function connectWebSocket() { + if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return; + 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}${withBase('/ws')}${query}`); app.ws = ws; + ws.onopen = () => updateConnectionIndicator('connected'); + ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; + ws.onerror = () => updateConnectionIndicator('connectionError'); + ws.onmessage = event => { + try { + const message = JSON.parse(event.data); + if (message.event === 'bootstrap') { + const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.systemSnapshotAt=Date.now(); app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + } + const data = message.data || {}; + if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } + else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } + else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } + else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } + else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } + else if (message.event === 'settings.updated') { + const settingsDirty=isFormDirty($('#settingsForm')), nightDirty=isFormDirty($('#nightModeForm')), haDirty=isFormDirty($('#homeAssistantForm')); + app.settings=data; + if (!haDirty) app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; + if (!settingsDirty) renderSettings(); + if (!nightDirty) renderNightSettings(); + if (!haDirty) renderHomeAssistantSettings(); + renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); + } + else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } + else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); } + else if (message.event === 'gree.frame_received') { + app.system = app.system || {}; + app.system.gree_received_frames = Number(data.total || 0); + app.system.gree_received_frames_by_device = {...(app.system.gree_received_frames_by_device || {})}; + if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0); + renderGreeFrameStats(); + renderSystemInfo(); + } + else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } + else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } + else if (message.event === 'log.created') { if(app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if(app.currentView === 'logs') loadLogs(); } + else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad(); + } catch (_) {} + }; +} diff --git a/web/js/router.js b/web/js/router.js new file mode 100644 index 0000000..9119da6 --- /dev/null +++ b/web/js/router.js @@ -0,0 +1,97 @@ +const VIEW_ROUTES = Object.freeze({ + dashboard: '/dashboard', + devices: '/devices', + zones: '/zones', + groups: '/groups', + schedules: '/schedules', + automations: '/automations', + simulation: '/simulation', + night: '/night-mode', + homeassistant: '/home-assistant', + settings: '/settings', + logs: '/events', +}); +const ROUTE_VIEWS = Object.freeze(Object.fromEntries(Object.entries(VIEW_ROUTES).map(([view, path]) => [path, view]))); +const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'sensors', 'custom']); +let routerInitialized = false; + +function currentHistoryPath() { + const tab = HISTORY_TABS.includes(app.historyTab) ? app.historyTab : 'overview'; + const params = new URLSearchParams(); + const hours = $('#historyHours')?.value || new URLSearchParams(location.search).get('hours') || '24'; + if (hours !== '24') params.set('hours', hours); + if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone); + if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice); + if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor); + if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries)); + const query = params.toString(); + return `/history/${tab}${query ? `?${query}` : ''}`; +} + +function pathForView(name) { + return name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || VIEW_ROUTES.dashboard); +} + +function appRelativePath(pathname = location.pathname) { + if (!APP_BASE) return pathname || '/'; + if (pathname === APP_BASE) return '/'; + if (pathname.startsWith(`${APP_BASE}/`)) return pathname.slice(APP_BASE.length) || '/'; + return pathname || '/'; +} + +function updateBrowserUrl(path, replace = false) { + const normalized = path.startsWith('/') ? path : `/${path}`; + const target = `${APP_BASE}${normalized}` || normalized; + const current = `${location.pathname}${location.search}`; + if (current === target) return; + history[replace ? 'replaceState' : 'pushState']({}, '', target); +} + +function restoreCurrentRoute(previousView) { + updateBrowserUrl(previousView === 'history' ? currentHistoryPath() : pathForView(previousView), true); +} + +function applyRouteFromLocation() { + const routePath = appRelativePath(); + const parts = routePath.split('/').filter(Boolean); + const first = parts[0] || 'dashboard'; + const params = new URLSearchParams(location.search); + const previousView = app.currentView; + + app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1'; + document.body.classList.toggle('simulation-standalone', app.standaloneSimulation); + + if (first === 'simulation') { + app.simulationScope = ['units', 'groups'].includes(params.get('scope')) ? params.get('scope') : 'units'; + app.simulationTarget = params.get('target') || 'all'; + } + + if (first === 'history') { + app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview'; + app.historyZone = params.get('zone') || 'all'; + app.historyDevice = params.get('device') || 'all'; + app.historySensor = params.get('sensor') || 'all'; + const hours = params.get('hours'); + if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; + if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart')); + if (!showView('history', {push: false, scroll: false})) restoreCurrentRoute(previousView); + return; + } + + const view = first === 'index.html' ? 'dashboard' : ROUTE_VIEWS[`/${first}`]; + if (!showView(view || 'dashboard', {push: false, scroll: false})) { + restoreCurrentRoute(previousView); + return; + } + + // Keep a canonical URL for the application root and legacy /index.html entry point. + if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true); +} + +function initRouter() { + if (!routerInitialized) { + window.addEventListener('popstate', applyRouteFromLocation); + routerInitialized = true; + } + applyRouteFromLocation(); +} diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js new file mode 100644 index 0000000..22dda33 --- /dev/null +++ b/web/js/settings-ui.js @@ -0,0 +1,231 @@ +function renderAccessTokens() { + const list = $('#accessTokenList'); + if (!list) return; + list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => ` +
    +
    ${esc(item.name)}${esc(item.token_prefix)}${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}
    + +
    `).join('') : `
    ${esc(tr('settings.noTokens'))}${esc(tr('settings.noTokensHint'))}
    `; +} + +function knownHaEntities() { + return [...new Set([ + ...Object.keys(app.sensorAliases || {}), + ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), + app.settings?.home_assistant?.default_entity_id, + app.settings?.home_assistant?.outdoor_entity_id, + ...app.historyData.sensors.map(row => row.entity_id), + ].filter(Boolean))].sort(); +} + +function renderSensorAliases() { + const host = $('#sensorAliasList'); if (!host) return; + const entities = knownHaEntities(); + host.innerHTML = entities.length ? entities.map(entity => `
    ${esc(entity)}
    `).join('') : `
    ${esc(tr('settings.noSensorAliases'))}
    `; +} + +function renderLogRetention() { + const select = $('#logRetentionDays'); if (!select || !app.settings) return; + [...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; }); + const days = String(app.settings.event_log_retention_days || 30); + if (![...select.options].some(option => option.value === days)) { + const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option); + } + select.value = days; +} + +function renderSettings() { + if (!app.settings) return; + const form = $('#settingsForm'); + if (!form) return; + form.controller_id.value = app.settings.controller_id || ''; + form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15; + form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5; + form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000'; + form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000; + form.simulator_enabled.checked = !!app.settings.simulator_enabled; + form.history_retention_days.value = app.settings.history_retention_days || 30; + form.event_log_retention_days.value = app.settings.event_log_retention_days || 30; + form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false; + form.suppress_device_beep.checked = !!app.settings.suppress_device_beep; + form.influx_enabled.checked = !!app.settings.influxdb?.enabled; + form.influx_version.value = String(app.settings.influxdb?.version || '2'); + form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30; + form.influx_url.value = app.settings.influxdb?.url || ''; + form.influx_database.value = app.settings.influxdb?.database || 'gree_controller'; + form.influx_username.value = app.settings.influxdb?.username || ''; + form.influx_password.value = ''; + form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); + form.influx_org.value = app.settings.influxdb?.org || ''; + form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller'; + form.influx_token.value = ''; + form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); + form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled; + form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames; + const n=app.settings.notifications||{}; + form.notifications_enabled.checked=!!n.enabled; form.notifications_mode.value=n.mode||'problems'; form.notifications_provider.value=n.provider||'pushover'; + form.pushover_app_token.value=''; form.pushover_user_key.value=''; form.slack_webhook_url.value=''; form.discord_webhook_url.value=''; + form.pushover_app_token.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.applicationTokenPlaceholder'); + form.pushover_user_key.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.userKeyPlaceholder'); + form.slack_webhook_url.placeholder=n.slack_configured?tr('settings.secretSaved'):'https://hooks.slack.com/services/…'; + form.discord_webhook_url.placeholder=n.discord_configured?tr('settings.secretSaved'):'https://discord.com/api/webhooks/…'; + form.notification_cooldown_seconds.value=n.cooldown_seconds||300; form.notification_failure_threshold.value=n.communication_failure_threshold||3; form.notification_target_timeout.value=n.target_timeout_minutes||60; + const alerts=n.alert_types||{}; + form.notification_alert_stale_sensor.checked=alerts.stale_sensor!==false; + form.notification_alert_sensor_errors.checked=alerts.sensor_errors!==false; + form.notification_alert_communication.checked=alerts.communication!==false; + form.notification_alert_target_timeout.checked=alerts.target_timeout!==false; + form.notification_alert_automation.checked=alerts.automation!==false; + form.notification_alert_control_errors.checked=alerts.control_errors!==false; + form.notification_alert_important_events.checked=alerts.important_events!==false; + form.notification_alert_other.checked=alerts.other!==false; + updateNotificationFields(); + updateInfluxFields(); + renderLogRetention(); + renderGreeFrameStats(); + renderSystemInfo(); + renderSimulationModeBanner(); + setSettingsTab(app.settingsTab); + markFormClean(form); +} + +function renderSimulationModeBanner() { + const banner = $('#simulationModeBanner'); + if (!banner) return; + const enabled = !!app.settings?.simulator_enabled; + banner.hidden = !enabled; + document.body.classList.toggle('simulation-mode-enabled', enabled); +} + +function setSettingsTab(tab) { + app.settingsTab = tab === 'gree' ? 'gree' : 'app'; + $$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; }); + $$('[data-settings-tab]').forEach(button => { + const active = button.dataset.settingsTab === app.settingsTab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); +} + +function renderSystemInfo() { + const host = $('#systemInfo'); + if (!host) return; + const elapsed = Math.max(0, Math.floor((Date.now() - Number(app.systemSnapshotAt || Date.now())) / 1000)); + const uptime = Number(app.system?.uptime_seconds || 0) + elapsed; + const dbPath = String(app.system?.database || ''); + const database = dbPath ? dbPath.split(/[\\/]/).filter(Boolean).pop() : '—'; + const ready = !!app.system?.control_ready; + const connected = app.connectionStatus === 'connected'; + const items = [ + [tr('settings.version'), app.system?.version || '—', 'version'], + [tr('settings.uptime'), formatDuration(uptime), 'uptime'], + [tr('settings.controlEngine'), ready ? tr('settings.ready') : tr('settings.syncing'), ready ? 'ok' : 'warn'], + [tr('settings.websocket'), tr(`status.${app.connectionStatus}`), connected ? 'ok' : 'warn'], + [tr('settings.apiAuth'), app.system?.auth_required ? tr('settings.enabled') : tr('settings.disabled'), 'neutral'], + [tr('settings.httpBind'), app.system?.bind || '—', 'mono'], + [tr('settings.database'), database, 'mono'], + [tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'], + ]; + host.innerHTML = `
    ${esc(tr('settings.systemState'))}

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

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

    ${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}
    ${items.map(([label,value,tone]) => `
    ${esc(label)}${esc(value)}
    `).join('')}
    `; +} + +function renderGreeFrameStats() { + const host = $('#greeFrameStats'); + if (!host) return; + const total = Number(app.system?.gree_received_frames || 0); + const byDevice = app.system?.gree_received_frames_by_device || {}; + const deviceRows = app.devices.filter(device => !device.simulated).map(device => { + const count = Number(byDevice[device.id] || 0); + return `
    ${esc(device.name || device.id)}${count.toLocaleString(locale())}
    `; + }).join(''); + host.innerHTML = `
    ${esc(tr('settings.receivedFramesTotal'))}${total.toLocaleString(locale())}
    ${deviceRows || `
    ${esc(tr('settings.receivedFramesDevices'))}0
    `}`; +} + +function renderNightSettings() { + if (!app.settings) return; + const form = $('#nightModeForm'); + if (!form) return; + form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled; + form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00'; + form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00'; + form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1); + form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false; + form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false; + markFormClean(form); +} + +function renderHomeAssistantSettings() { + if (!app.settings) return; + const form = $('#homeAssistantForm'); + if (!form) return; + form.ha_url.value = app.settings.home_assistant?.url || ''; + form.ha_token.value = ''; + form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'); + form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || ''; + form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || ''; + form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(app.settings.home_assistant?.sensor_stale_after_seconds || 300) / 60))); + form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; + form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; + renderSensorAliases(); + renderAccessTokens(); + markFormClean(form); +} + +function updateInfluxFields() { + const version = $('#settingsForm [name=influx_version]')?.value || '2'; + $$('[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); + renderDebugOverlay(); +} + +function renderDebugOverlay() { + const overlay = $('#debugOverlay'); if (!overlay) return; + const enabled = !!app.settings?.debug?.overlay_enabled; + overlay.hidden = !enabled; + if (!enabled) return; + if (!['all','requests','gree'].includes(app.debugFilter)) app.debugFilter = 'all'; + const status = $('#debugOverlayStatus'); + if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly'); + $$('[data-debug-filter]', overlay).forEach(button => { + const active = button.dataset.debugFilter === app.debugFilter; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); + const host = $('#debugOverlayLines'); if (!host) return; + const visible = app.debugLines.filter(line => { + if (app.debugFilter === 'gree') return line.source === 'GREE'; + if (app.debugFilter === 'requests') return line.source === 'HTTP'; + return true; + }).slice(-120); + host.innerHTML = visible.length ? visible.map(line => { + const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`; + const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : 'api'; + return `
    ${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
    `; + }).join('') : `
    ${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : 'debug.empty'))}
    `; + host.scrollTop = host.scrollHeight; +} + +async function loadDebugBacklog() { + if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return; + try { + const data = await api('/api/events?limit=60'); + app.debugLines = (data.events || []).reverse().map(item => ({source:'API', kind:item.kind, message:item.message, timestamp:item.timestamp, data:item.metadata})).slice(-120); + app.debugBacklogLoaded = true; + renderDebugOverlay(); + } catch (_) {} +} + +function formatDuration(seconds) { + const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60); + return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; +} + +setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000); + diff --git a/web/js/settings.js b/web/js/settings.js new file mode 100644 index 0000000..3a189d1 --- /dev/null +++ b/web/js/settings.js @@ -0,0 +1,289 @@ + +function currentSettingsBody() { + const settings = app.settings || {}; + const influx = settings.influxdb || {}; + const ha = settings.home_assistant || {}; + return { + controller_id: settings.controller_id || 'gree-controller', + simulator_enabled: !!settings.simulator_enabled, + poll_interval_seconds: Number(settings.poll_interval_seconds || 15), + zone_interval_seconds: Number(settings.zone_interval_seconds || 5), + discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000), + discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000', + house_mode: settings.house_mode || 'cool', + house_power_enabled: settings.house_power_enabled !== false, + control_strategy: 'setpoint', + outdoor_assist_enabled: !!settings.outdoor_assist_enabled, + history_retention_days: Number(settings.history_retention_days || 30), + 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), + alert_types: { + stale_sensor: settings.notifications?.alert_types?.stale_sensor !== false, + sensor_errors: settings.notifications?.alert_types?.sensor_errors !== false, + communication: settings.notifications?.alert_types?.communication !== false, + target_timeout: settings.notifications?.alert_types?.target_timeout !== false, + automation: settings.notifications?.alert_types?.automation !== false, + control_errors: settings.notifications?.alert_types?.control_errors !== false, + important_events: settings.notifications?.alert_types?.important_events !== false, + other: settings.notifications?.alert_types?.other !== false, + }, + }, + night_mode: { + enabled: !!settings.night_mode?.enabled, + start_time: settings.night_mode?.start_time || '22:00', + end_time: settings.night_mode?.end_time || '06:00', + max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1), + force_quiet: settings.night_mode?.force_quiet !== false, + use_native_sleep: settings.night_mode?.use_native_sleep !== false, + }, + influxdb: { + enabled: !!influx.enabled, + version: String(influx.version || '2'), + url: influx.url || '', + database: influx.database || 'gree_controller', + username: influx.username || '', + password: '', + org: influx.org || '', + bucket: influx.bucket || 'gree_controller', + token: '', + history_threshold_days: Number(influx.history_threshold_days || 30), + }, + debug: { + overlay_enabled: !!settings.debug?.overlay_enabled, + gree_frames: !!settings.debug?.gree_frames, + }, + home_assistant: { + url: ha.url || '', + token: '', + default_entity_id: ha.default_entity_id || '', + outdoor_entity_id: ha.outdoor_entity_id || '', + sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300), + allow_invalid_tls: !!ha.allow_invalid_tls, + sensor_aliases: {...(ha.sensor_aliases || {})}, + }, + }; +} + +function settingsBodyFromForm(form) { + const raw = Object.fromEntries(new FormData(form)); + const body = currentSettingsBody(); + body.controller_id = raw.controller_id; + body.simulator_enabled = form.simulator_enabled.checked; + body.poll_interval_seconds = Number(raw.poll_interval_seconds); + body.zone_interval_seconds = Number(raw.zone_interval_seconds); + body.discovery_timeout_ms = Number(raw.discovery_timeout_ms); + body.discovery_broadcast = raw.discovery_broadcast; + body.history_retention_days = Number(raw.history_retention_days); + body.history_compaction_enabled = form.history_compaction_enabled.checked; + body.event_log_retention_days = Number(raw.event_log_retention_days); + body.suppress_device_beep = form.suppress_device_beep.checked; + body.influxdb = { + enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url, + database: raw.influx_database, username: raw.influx_username, password: raw.influx_password, + org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token, + history_threshold_days: Number(raw.influx_threshold_days), + }; + body.debug = {overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked}; + body.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), + alert_types: { + stale_sensor: form.notification_alert_stale_sensor.checked, + sensor_errors: form.notification_alert_sensor_errors.checked, + communication: form.notification_alert_communication.checked, + target_timeout: form.notification_alert_target_timeout.checked, + automation: form.notification_alert_automation.checked, + control_errors: form.notification_alert_control_errors.checked, + important_events: form.notification_alert_important_events.checked, + other: form.notification_alert_other.checked, + }, + }; + return body; +} + +function nightSettingsBodyFromForm(form) { + const raw = Object.fromEntries(new FormData(form)); + const body = currentSettingsBody(); + body.night_mode = { + enabled: form.night_mode_enabled.checked, + start_time: raw.night_mode_start, + end_time: raw.night_mode_end, + max_fan_speed: Number(raw.night_mode_max_fan_speed), + force_quiet: form.night_mode_force_quiet.checked, + use_native_sleep: form.night_mode_native_sleep.checked, + }; + return body; +} + +function homeAssistantSettingsBodyFromForm(form) { + const raw = Object.fromEntries(new FormData(form)); + const body = currentSettingsBody(); + body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked; + body.home_assistant = { + url: raw.ha_url, + token: raw.ha_token, + default_entity_id: raw.ha_entity_id, + outdoor_entity_id: raw.ha_outdoor_entity_id, + sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))), + allow_invalid_tls: form.ha_allow_invalid_tls.checked, + sensor_aliases: {...(app.sensorAliases || {})}, + }; + return body; +} + +async function saveRuntimeSettings(body, notify = true) { + app.settings = await api('/api/settings', {method:'PUT', body}); + app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; + renderSettings(); + renderNightSettings(); + renderHomeAssistantSettings(); + renderHouseClimate(); + renderSimulationModeBanner(); + renderSystemInfo(); + renderDebugOverlay(); + scheduleControlPlanLoad(); + if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); + if (notify) toast(tr('common.saved')); + return app.settings; +} + +$('#settingsForm').addEventListener('submit', async event => { + event.preventDefault(); const form = event.currentTarget; + await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true)); +}); + +$('#nightModeForm')?.addEventListener('submit', async event => { + event.preventDefault(); const form = event.currentTarget; + await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true)); +}); + +$('#homeAssistantForm')?.addEventListener('submit', async event => { + event.preventDefault(); const form = event.currentTarget; + await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true)); +}); + +$('#addSensorAlias')?.addEventListener('click', () => { + const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName'); + const entity = entityInput.value.trim(), alias = aliasInput.value.trim(); + if (!entity || !alias) return toast(tr('settings.aliasRequired'), true); + app.sensorAliases[entity] = alias; + entityInput.value = ''; aliasInput.value = ''; + renderSensorAliases(); renderHistoryNavigation(); + updateDirtyIndicator($('#homeAssistantForm')); +}); + +document.addEventListener('input', event => { + const input = event.target.closest('[data-sensor-alias]'); if (!input) return; + const entity = input.dataset.sensorAlias, alias = input.value.trim(); + if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity]; + updateDirtyIndicator($('#homeAssistantForm')); +}); + +document.addEventListener('click', event => { + const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return; + delete app.sensorAliases[button.dataset.clearSensorAlias]; + renderSensorAliases(); renderHistoryNavigation(); + updateDirtyIndicator($('#homeAssistantForm')); +}); + +$('#saveLogRetention')?.addEventListener('click', async () => { + const days = Number($('#logRetentionDays')?.value || 30); + try { + const result = await api('/api/events/retention', {method:'PUT', body:{days}}); + app.settings.event_log_retention_days = result.days; + $('#settingsForm').event_log_retention_days.value = result.days; + renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days})); + } catch (error) { toast(error.message, true); } +}); + +$('#createAccessToken').addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}}); + if (result.item) app.accessTokens.unshift(result.item); + renderAccessTokens(); + $('#generatedAccessToken').value = result.token || ''; + openDialog('generatedTokenDialog'); + toast(tr('toast.tokenCreated')); + } catch (error) { toast(error.message, true); } + finally { button.disabled = false; } +}); + +$('#copyAccessToken').addEventListener('click', async () => { + const input = $('#generatedAccessToken'); + try { + await navigator.clipboard.writeText(input.value); + toast(tr('toast.tokenCopied')); + } catch (_) { + input.select(); + document.execCommand('copy'); + toast(tr('toast.tokenCopied')); + } +}); + +$('#haTest').addEventListener('click', async event => { + const form=$('#homeAssistantForm'), button=event.currentTarget; + if (!validateForm(form)) return; + button.disabled = true; + const idle = button.textContent; + button.textContent = tr('settings.testingHa'); + try { + await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false); + const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null; + const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}}); + markFormClean(form); + toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); + } catch(error){presentFormError(form,error);} + finally { button.disabled=false; button.textContent=idle; } +}); + +$('#simulationRefresh')?.addEventListener('click', async () => { + try { await loadControlPlan(); toast(tr('common.updated')); } + catch (error) { toast(error.message, true); } +}); + +$('#exportSettings').addEventListener('click', async () => { + try { + const data = await api('/api/settings/export'); + const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `gree-controller-settings-${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href); + toast(tr('toast.exported')); + } catch (error) { toast(error.message, true); } +}); + +$('#importSettings').addEventListener('click', () => $('#importSettingsFile').click()); +$('#importSettingsFile').addEventListener('change', async event => { + const file = event.target.files?.[0]; if (!file) return; + try { + if (!confirm(tr('settings.importConfirm'))) return; + const body = JSON.parse(await file.text()); + await api('/api/settings/import', {method:'POST', body}); + app.debugBacklogLoaded = false; + await loadBootstrap(); + toast(tr('toast.imported')); + } catch (error) { toast(error.message, true); } + finally { event.target.value = ''; } +}); + +document.addEventListener('change', event => { + const target=event.target; + if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} + else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} + else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} + else if(target.name==='influx_version') updateInfluxFields(); +}); + + diff --git a/web/styles.css b/web/styles.css index cc8a1f7..bd04f6b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -423,8 +423,12 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; } #historyTemperatureChart, #historyOperationChart { width: 100%; min-width: 720px; } #historyTemperatureChart { height: 360px; } #historyOperationChart { height: 260px; } -.legend { flex-wrap: wrap; gap: 9px 18px; } -.legend-line { width: 18px; height: 3px; border-radius: 999px; background: var(--legend-color, var(--accent)); } +.legend { flex-wrap: wrap; gap: 7px 10px; } +.legend-item { display:inline-flex; align-items:center; gap:7px; min-height:30px; padding:5px 8px; border:1px solid transparent; border-radius:9px; background:transparent; color:var(--muted); font:inherit; cursor:pointer; } +.legend-item:hover { border-color:var(--line); background:var(--surface-muted); color:var(--text-soft); } +.legend-item.is-hidden { opacity:.42; text-decoration:line-through; } +.legend-item:focus-visible { outline:2px solid color-mix(in srgb,var(--accent) 55%,transparent); outline-offset:1px; } +.legend-line { flex:0 0 18px; width: 18px; height: 3px; border-radius: 999px; background: var(--legend-color, var(--accent)); } @media (max-width: 620px) { .history-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart-toolbar { display: grid; grid-template-columns: 1fr 1fr; } @@ -1264,7 +1268,9 @@ textarea[aria-invalid="true"] { .chart-zoom-controls button:disabled { cursor: default; opacity: .34; } .chart-zoom-reset { color: var(--muted) !important; font-size: 10px !important; font-variant-numeric: tabular-nums; } .chart-wrap { overscroll-behavior-inline: contain; scroll-behavior: smooth; } -.chart-wrap canvas { touch-action: pan-x pan-y; } +.chart-wrap canvas { touch-action: pan-y; user-select:none; -webkit-user-select:none; } +.chart-selection { position:absolute; z-index:4; border:1px solid color-mix(in srgb,var(--accent) 76%,transparent); border-radius:4px; background:color-mix(in srgb,var(--accent) 16%,transparent); pointer-events:none; } +@media (hover:hover) and (pointer:fine) { .history-chart-card .chart-wrap canvas { cursor:crosshair; } } @media (max-width: 700px) { .toolbar-picker { flex-basis: 38px; width: 38px; height: 38px; border-radius: 10px; gap: 2px; } @@ -1302,3 +1308,17 @@ textarea[aria-invalid="true"] { border-color: color-mix(in srgb, var(--accent) 48%, var(--line-strong)); box-shadow: 0 8px 30px rgba(0,0,0,.18), 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); } + +/* Standalone HTTP error pages */ +.error-page { min-height: 100dvh; } +.error-topbar { grid-template-columns: 1fr; } +.error-main { display: grid; place-items: center; min-height: calc(100dvh - 72px); padding: 32px 18px max(48px, env(safe-area-inset-bottom)); } +.error-card { width: min(620px, 100%); padding: clamp(28px, 7vw, 52px); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line)); border-radius: 30px; background: var(--surface); box-shadow: 0 24px 80px rgba(0, 0, 0, .18); } +.error-code { margin: 12px 0 -4px; color: var(--accent); font-size: clamp(72px, 20vw, 132px); font-weight: 250; line-height: .92; letter-spacing: -.075em; } +.error-card h1 { margin-bottom: 10px; } +.error-card .lead { margin-bottom: 0; } +.error-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; } +.button-link { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 11px 15px; border-radius: 13px; text-decoration: none; transition: transform .15s ease, background .15s ease, opacity .15s ease; } +.button-link:active { transform: scale(.97); } +.button-link.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; } +.button-link.primary:hover { background: var(--accent-strong); } diff --git a/web/sw.js b/web/sw.js index ad01b6d..76a8ac6 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,13 +1,13 @@ -const CACHE = 'gree-controller-v0813-settings-debug-docs'; +const CACHE = 'gree-controller-__GREE_ASSET_CACHE__'; 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'), path('/lang/pl.json')]; +const ASSETS = [path('__GREE_STYLES_ASSET__'), path('__GREE_APP_ASSET__'), path('__GREE_THEME_INIT_ASSET__'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json'), path('/lang/pl.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 => { 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 => { + if (event.request.method !== 'GET' || url.pathname.startsWith(path('/api/')) || event.request.mode === 'navigate') return; + event.respondWith(caches.match(event.request).then(cached => cached || fetch(event.request).then(response => { const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response; - }).catch(() => caches.match(event.request).then(response => response || caches.match(path('/'))))); + }))); });