v0.4.1
This commit is contained in:
+47
-54
@@ -1,71 +1,64 @@
|
||||
# Build report — v0.3.8
|
||||
|
||||
## Fix in this release
|
||||
|
||||
- Fixed multi-NIC LXC failures where `getifaddrs()` returned `EAFNOSUPPORT` (`os error 97`).
|
||||
- The systemd sandbox now allows `AF_NETLINK`, which Linux `getifaddrs()` uses to enumerate interface addresses.
|
||||
- Existing hardening remains enabled (`NoNewPrivileges`, `ProtectSystem`, restricted address families).
|
||||
- With `GREE_CONTROLLER_GREE_INTERFACE=eth1`, GREE UDP sockets can now resolve and bind the IPv4 address assigned to `eth1`.
|
||||
|
||||
## Validation
|
||||
|
||||
- systemd unit includes `AF_NETLINK`.
|
||||
- shell scripts pass `bash -n`.
|
||||
- JSON language files parse successfully.
|
||||
- all SQL remains centralized in `src/queries.rs`.
|
||||
- Rust compilation is not available in the packaging environment; `scripts/update.sh` runs Cargo tests/build on the target LXC before installation.
|
||||
|
||||
---
|
||||
|
||||
# GREE Controller v0.3.6 - build and validation report
|
||||
# GREE Controller v0.4.1 - build and validation report
|
||||
|
||||
## Scope
|
||||
|
||||
Version 0.3.6 fixes real-device binding failures observed on a mixed five-unit GREE network in a multi-NIC LXC.
|
||||
Version 0.4.1 is a Web UI redesign release. Backend HVAC logic, protocol handling, SQLite layout and Home Assistant API behavior are intentionally unchanged from v0.4.0.
|
||||
|
||||
### Protocol fixes
|
||||
## UI redesign
|
||||
|
||||
- GREE AES-GCM decoding now accepts non-canonical Base64 trailing bits and optional padding. This matches the forgiving decoding behavior used by established Python GREE implementations and fixes `Invalid last symbol ... offset 21` errors from real Wi-Fi modules.
|
||||
- GCM decrypted payloads discard `0xff` filler bytes used by some modules.
|
||||
- ECB decoding keeps normal PKCS#7 validation but has a compatibility fallback that trims decrypted data at the last JSON `}` when a legacy module returns non-standard padding.
|
||||
- Bind waits ignore late discovery packets instead of accidentally treating a `dev` packet as the bind result.
|
||||
- Before each bind, the controller refreshes the short GREE bind window with a subnet broadcast scan when the device is on a directly connected IPv4 network. The same UDP socket is then used immediately for the bind request.
|
||||
- ECB/GCM fallback remains enabled and successful binding persists the detected protocol and device key.
|
||||
The embedded interface was rebuilt around a Supabase-inspired dashboard language while keeping original GREE Controller branding and information architecture:
|
||||
|
||||
### Multi-NIC behavior
|
||||
- compact neutral surfaces instead of oversized cards,
|
||||
- thin theme-aware borders and 6-8 px radii,
|
||||
- restrained green brand accent,
|
||||
- no decorative shadows or background gradients,
|
||||
- full desktop sidebar for Dashboard, Devices, Zones, History, Schedules, Automations, Settings and Events,
|
||||
- existing five-item bottom navigation retained on phones,
|
||||
- compact inputs, selects, segmented controls and dialog controls,
|
||||
- custom inline navigation SVG icons with no external icon dependency,
|
||||
- themed, narrow scrollbars with transparent tracks,
|
||||
- Light, Dark and System themes driven from the same CSS token set,
|
||||
- updated PWA theme colors and service-worker cache version.
|
||||
|
||||
- If `GREE_CONTROLLER_GREE_INTERFACE` is configured, all GREE UDP sockets are bound to the current IPv4 address of that interface.
|
||||
- If no GREE interface is configured, the controller now automatically selects the local IPv4 address whose subnet contains the target GREE device. For the reported LXC this resolves `10.87.65.x` traffic to the `10.87.65.27/25` interface instead of leaving the socket bound to `0.0.0.0`.
|
||||
- The automatically selected subnet broadcast is also used to refresh the bind window (for example `10.87.65.127:7000`).
|
||||
## Offline Tailwind tooling
|
||||
|
||||
Added:
|
||||
|
||||
- `package.json`,
|
||||
- `tailwind.config.js`,
|
||||
- `web/tailwind.input.css`,
|
||||
- `scripts/build-web.sh`.
|
||||
|
||||
Tailwind is development/build tooling only. The production controller serves the committed `web/styles.css`, which is embedded in the Rust binary. The browser never contacts a Tailwind CDN and the installed LXC service does not require Node.js.
|
||||
|
||||
If local Tailwind dependencies are installed, `dev.sh`, `install.sh` and `update.sh` rebuild the CSS automatically. Otherwise they use the committed prebuilt CSS.
|
||||
|
||||
The packaging environment could not download the npm Tailwind dependency because external npm access timed out. This does not affect the shipped UI because `web/styles.css` is already present and validated. `scripts/build-web.sh --install` can install the optional local build dependency on a development host with npm access.
|
||||
|
||||
## Validation performed
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| JavaScript syntax (`node --check web/app.js`) | PASS |
|
||||
| EN/PL JSON parsing | PASS |
|
||||
| Shell syntax (`bash -n scripts/*.sh`) | PASS |
|
||||
| Python syntax for scripts and HA integration | PASS |
|
||||
| SQL remains centralized in `src/queries.rs` | PASS |
|
||||
| No operator `.sh`/`.py` scripts in the project root | PASS |
|
||||
| Package naming remains `gree_controller` / `GREE_CONTROLLER_*` | PASS |
|
||||
| ZIP integrity | performed during release packaging |
|
||||
Passed:
|
||||
|
||||
The packaging environment does not contain the Rust toolchain, so the final Rust type-check, tests and optimized build are intentionally performed by `scripts/update.sh` on the target LXC before the installed binary is replaced.
|
||||
- `web/app.js`: `node --check`,
|
||||
- `web/index.html`: Python HTML parser,
|
||||
- `web/styles.css`: `tinycss2` stylesheet parser,
|
||||
- `lang/*.json`: JSON parsing,
|
||||
- `scripts/*.sh`: `bash -n`,
|
||||
- `scripts/generate_ha_migration.py`: Python bytecode compilation,
|
||||
- `web/tailwind.input.css` semantic CSS section matches the committed `web/styles.css`,
|
||||
- no `node_modules` directory is included,
|
||||
- project-owned SQL remains centralized in `src/queries.rs`.
|
||||
|
||||
## Recommended LXC test
|
||||
## Rust build
|
||||
|
||||
The packaging environment does not provide a working Cargo toolchain, so `cargo test --all-targets` and the release build were not executed here. On the target LXC, `scripts/install.sh` / `scripts/update.sh` run the Rust tests and release build before replacing the installed service.
|
||||
|
||||
## Expected LXC update
|
||||
|
||||
```bash
|
||||
unzip gree-controller-v0.4.1.zip
|
||||
cd gree-controller
|
||||
sudo ./scripts/update.sh
|
||||
sudo ./scripts/configure-gree-network.sh eth1
|
||||
journalctl -u gree-controller -f
|
||||
```
|
||||
|
||||
Expected request logs should show a concrete local address such as `10.87.65.27:<port>` rather than `0.0.0.0:<port>`.
|
||||
|
||||
## v0.3.8 legacy V1 bind compatibility
|
||||
|
||||
- Canonicalize the on-wire GREE device identifier to lowercase hexadecimal for `tcid` and inner `mac` fields.
|
||||
- This specifically targets legacy V1 modules such as the `502cc6...` family which can answer discovery yet silently ignore bind packets when the identifier casing differs from the value returned by discovery.
|
||||
- Database IDs, friendly names and stored MAC display values are unchanged.
|
||||
- Existing V2/GCM crypto and the `eth1`/AF_NETLINK networking fixes are unchanged.
|
||||
Runtime configuration and SQLite data remain under `/etc/gree-controller.env` and `/var/lib/gree-controller/` and are preserved by the updater.
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.3.8"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.3.8"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+34
-38
@@ -1,66 +1,62 @@
|
||||
6b10bf7903a32a6cdd902d990911105589690deafe7da12e5b3b9c994c1fd4f6 ./.env.example
|
||||
2fe1cf4e544fead5ae58436145a5b45e7a5a105143dfd815d28e307c94d5d19b ./.gitignore
|
||||
1e1867ebd5cd1633d511a4a65853da9a2ef1c018dc4e66d823e20ae032e0d1a7 ./BUILD_REPORT.md
|
||||
6b2eacdaf0a8f285fd832b39617e849e992fd3f6ab094f13da9c453c0c99b79d ./Cargo.toml
|
||||
da945a594f47b2b0c326cc0275b10a23cb509f545a1841ba9c57f00086e553ff ./.env.example
|
||||
aa544ed9f25f6db239c53472118440ed58a588236d989d21e5c95279371b8025 ./.gitignore
|
||||
f9ddac288965a3726f82f7c1b14949102a385842e305e407d5e8ca379ea30d7a ./BUILD_REPORT.md
|
||||
157c32f532d7daf8035381c724bb44455e5a2b4749bd83bd5304fba6fdc292b3 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
3c552aab65ddba02d79835f2cec13c9cde5328774503c53e8461fa54bce9cc41 ./README.md
|
||||
4a40aadbeeb73677932b7ce374a56fd00f0eef4f7cfda68f102fc0e6f69602e4 ./README.md
|
||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
||||
37453ecce2c7290ecae5ada4249cfd36bc70fb50c479aa680b74e4f1bc707251 ./docs/API.md
|
||||
f0a1c6e7c932c3ceeccfc1c86e071966f7d52d0766e58105609e4de5674e7a78 ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||
3d754c7120a086654f2666cac510b10a76529aa849c012a85427b0472a3ac261 ./docs/LXC.md
|
||||
a728ddc324613671932bd83522155b09c7503d4fbcd0d04d8e217676ea9e10e4 ./docs/PROJECT_SPEC.md
|
||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||
42bc9498478c2944ab3b2bdff6a09a37a2ffcdda1bff0bf259749886768b2c53 ./docs/PROJECT_SPEC.md
|
||||
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
9151cd32bfcea786eb8a9af9ca11af775abdc6bf23b55d6a291b76433473eb2a ./home-assistant/custom_components/gree_controller/__pycache__/__init__.cpython-313.pyc
|
||||
8c3753163d45895da5856525fe3e988a38c73bce4457b1358200453cc9a9ac2c ./home-assistant/custom_components/gree_controller/__pycache__/api.cpython-313.pyc
|
||||
e0d8043f39885375cfecf8e82153a257f2166e5319e975dbbb648482ec77474e ./home-assistant/custom_components/gree_controller/__pycache__/climate.cpython-313.pyc
|
||||
32097fe487c1c5fb428230d35a39f62c2bc17285036604315ec80545e8c185a5 ./home-assistant/custom_components/gree_controller/__pycache__/config_flow.cpython-313.pyc
|
||||
05791fb1cb4345726506a20ef5b36d355e8235b8ccd46a81a2b8ce5b42e4c3f7 ./home-assistant/custom_components/gree_controller/__pycache__/const.cpython-313.pyc
|
||||
bf1a90d03325dd37a86a05b586faf387bfd5a02732fd2c0e4d998369a13ba70f ./home-assistant/custom_components/gree_controller/__pycache__/coordinator.cpython-313.pyc
|
||||
cc4f2ab07b1a641bdc36f3738280fa643baac617d753863c288e5788b1c3d455 ./home-assistant/custom_components/gree_controller/__pycache__/entity_map.cpython-313.pyc
|
||||
e0cf725c9f84be51cdbd83a5ab12b2b8288f90b36623cb4671cefcbf04378504 ./home-assistant/custom_components/gree_controller/api.py
|
||||
fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
||||
e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py
|
||||
2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
4e0f31984b94f3da7fd98e3804b591bbe1c79d2c05b04b7c4fb484a4caec587e ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
1d6057dfb31c306e2d23397b549eb5c0d19e412055b2aac4c7587a0baedb8522 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
c604815c850574c573315306f368a8379ab87f5e2a99aca32be3131b05070afa ./lang/en.json
|
||||
0b2d174258610440d5d4c2d6c5bec5ecc2528f41946bf7d59cc2206aab50ce58 ./lang/pl.json
|
||||
09caaa48f9979f209b96e6d0feebbecda053b997c470a5d80abb80b7fb06a484 ./scripts/README.md
|
||||
896caae04468f743d85d011638a0683253d8271c0db8365aea9bcdc646795cf1 ./scripts/__pycache__/generate_ha_migration.cpython-313.pyc
|
||||
be8c4bf17723d5e2e23e774a679ca75b02ab49825a93201237bf58cdd4974136 ./scripts/common.sh
|
||||
85f9562a7b2cc4a95836f76d8c02dd8a6689d675e778427416d18f70ad20ae22 ./lang/en.json
|
||||
81077c3015d026ecc3150bed6351d97079aef121c9994a5a99a7c44226c8100a ./lang/pl.json
|
||||
09739bb5f96f4a6f1124a2c4fa6bd7f91ebc30b3b38f19c2759647374aefb7a9 ./package.json
|
||||
39a34270e0ba51f0ff2e4b6d3990754857d285471a69430c5c07ce46ebbb82b6 ./scripts/README.md
|
||||
08a77150c28a97da0bbd7278f59ecbdfa278c4741b902af6aae2d7fffb9e9138 ./scripts/build-web.sh
|
||||
2a49c0cbcd1f2bad2128193c811168768c4d96886f701f3e03e75e2a64a4c657 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
054e6862857fd1d02dabedd977d4175c2011f451aba51b5d4fc3a3363e66e80f ./scripts/dev.sh
|
||||
05340b11ffa6522ad7302c48d38da3aa3637a1f63b64e6c6dd4c24e254c18db2 ./scripts/dev.sh
|
||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||
a89ecb7367e56d20a85ad8bb02895ecd07e1f8b10f6bda3ca79ce656651918f5 ./scripts/install.sh
|
||||
84ec7786cc37b0421a3358024af50151624131ac1dd0852a5bfd2897116d3eda ./scripts/install.sh
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
69a1c2ad30685cac517eeb18a27d40368506dc5e54b57d943c324339303afc2b ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
9122d43347e3fb34276fa6bfee5108a078c0ba0923e6037ff79e6989a84f1506 ./src/api.rs
|
||||
e986a3d5fe53d9d00bf177a8b2a6e825aea366c30ef86ced2745774cbb17df30 ./src/config.rs
|
||||
54f3449ea140b0a0eefb8cff1d95090e62d5a955e50fcad47bc10b669ea3d68e ./src/db.rs
|
||||
d4df2f22ac481d8859e74593f72b30b246b6b23ea214ca274638af5a32bd8f4f ./src/engine.rs
|
||||
51c7a18547c39cae7eb04ff65b6910264763481ebc2e4ad696e44ff56e520d55 ./scripts/update.sh
|
||||
a30e43d12685bb6351fd845a0b345b6c9eb931bcb4d9abec994c8d3ce5b99db6 ./src/api.rs
|
||||
5138f9762540da497360667bd93e5e7274515a37baf3ca3c19c38b23a57c9e2b ./src/config.rs
|
||||
e58cc12a0343c089cceb722aac18162052c2a74ed25ac883dcc5def92d50e65b ./src/db.rs
|
||||
3c7dea320a9fd2535f195f63b5a17cebec4a1677b5fc247b834f8a14a8de9a39 ./src/engine.rs
|
||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||
f85cb4ba6435843431d93779aa0653fd24ec8987e06ef36efde096cbea321d7a ./src/home_assistant.rs
|
||||
27eca87a9078aad7a7a4ef1fc39ce7ddaffcf05466acb995d571e0a35dfe898c ./src/main.rs
|
||||
a5923f8fb72ebac68c13e4767dd35012041e2783167969521e757bf502fafabd ./src/models.rs
|
||||
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
|
||||
8f4fa47395451b2d9ee6270aae63dd8e7df9fa74182a3429b10aadac1029b090 ./src/main.rs
|
||||
ee6968b187f3782cfbf27e2f39a0e07187dc58b66ac8b4ac96f340b62c8f42ee ./src/models.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
b66ae7c0186b6cc3939d922b0a83f763fd670662c170c97cebca8235d99f663c ./src/protocol/gree.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
c80164631bba476db29469fa43b28d73f68412c06bafabe2143ab8d4cd330a52 ./src/queries.rs
|
||||
521070a4c63bec73372cf4873f20c5fad23c37db4df7199bcc5bf6f6e566b3f6 ./src/state.rs
|
||||
3bd56018114a7199082e9e9d973107b73cb8259693a80743f4022364cda62095 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
9d05fe7219a4e9806239792169417d577c56a38bea4561e4064ae4cf41418257 ./web/app.js
|
||||
b6bba1e1e7127d06c885a9f8fffbf0becde339e0f2464da8b4725f11ddabe433 ./web/favicon.svg
|
||||
45f0ddc583cf0ee3197dda5c72209f80389291e33c72a29e7555ebaa329819de ./web/index.html
|
||||
42143de65d81083938fc82aa601a38fb9f865860d628752580b0f1c8b77275d4 ./web/manifest.webmanifest
|
||||
3b91751dd418fca60e888a4d4f5e077683c12332cd76b9d05a6dbd18d8599fa2 ./web/styles.css
|
||||
2ecdd077b73c4c725de8b642c6658992bcefe99b08a22a78eaf7903eaf9f813a ./web/sw.js
|
||||
24f1a398b6a31f337a8d807fba133f518334a918598a6003ea52b158b0daba41 ./tailwind.config.js
|
||||
36d92e4b23859e1d857bd6b95fc0a67b7b96cfd31f90e4a37c5e9c4bfaf8d456 ./web/app.js
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
88d425fe7545c85ada1228ced4bec5e86194ed7abe03a24da4738add8849a809 ./web/index.html
|
||||
6db5f01ef1284c6de175c411fb7014a7b312436bc5964ee8086ed94650047b8c ./web/manifest.webmanifest
|
||||
d31c82797e155bd72795fefc605ffc208ac80785c3b3580c6f3301cbffc2146b ./web/styles.css
|
||||
7c4eb0cb8e89cdb219e8f7d1cb77c754184a25cfd2334da8a507d8e85724d95d ./web/sw.js
|
||||
6afdbfa97801477ec1e7d361444bb5ddc20478ce8a2ce1be49869d4cb07fbd92 ./web/tailwind.input.css
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
|
||||
|
||||
Current version: **0.3.8**.
|
||||
Current version: **0.4.1**.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -13,10 +13,14 @@ Current version: **0.3.8**.
|
||||
- automatic ECB/GCM detection from discovery responses and bind fallback,
|
||||
- power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light,
|
||||
- SQLite state/history/event storage,
|
||||
- temperature zones with hysteresis and minimum ON/OFF protection,
|
||||
- weekly schedules including ranges that cross midnight,
|
||||
- temperature/time automations,
|
||||
- smart thermostat zones with global house Heat/Cool/Off mode and per-zone overrides,
|
||||
- setpoint modulation that keeps indoor units powered during normal operation instead of repeatedly cycling power,
|
||||
- Comfort/Sleep/Away profiles, temporary overrides and one-tap **Sleep now**,
|
||||
- editable weekly schedules plus Family/Child room/Bedroom/Workday/Always-comfort templates,
|
||||
- temperature/time automations for advanced exceptions,
|
||||
- per-zone optional Home Assistant room-temperature sensors with GREE fallback,
|
||||
- optional Home Assistant outdoor-temperature assist for setpoint/fan decisions,
|
||||
- opt-in support for self-signed/invalid HTTPS certificates on the outbound HA sensor connection,
|
||||
- combined zone temperature using configurable GREE/external sensor weighting and discrepancy protection,
|
||||
- REST API and WebSocket updates,
|
||||
- responsive PWA optimized for phones,
|
||||
@@ -35,7 +39,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
|
||||
On Debian, Ubuntu or an LXC container:
|
||||
|
||||
```bash
|
||||
unzip gree-controller-v0.3.8.zip
|
||||
unzip gree-controller-v0.4.1.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -81,7 +85,18 @@ Appearance selector:
|
||||
- Light,
|
||||
- Dark.
|
||||
|
||||
The selected appearance is stored in the `gree_controller_theme` cookie. The interface uses flat surfaces and borders; decorative UI shadows were removed in v0.2.0.
|
||||
The selected appearance is stored in the `gree_controller_theme` cookie. Version 0.4.1 refreshes the interface with a Supabase-inspired dashboard language: layered neutral surfaces, compact controls, thin borders, small radii, a restrained green brand accent, a desktop sidebar, mobile bottom navigation, and theme-aware scrollbars. The design remains original to GREE Controller and does not copy Supabase branding or application code.
|
||||
|
||||
### Offline Tailwind workflow
|
||||
|
||||
The running controller never loads Tailwind, fonts, JavaScript, or CSS from a CDN. `web/styles.css` is embedded directly into the Rust binary. Tailwind is an optional local build tool for UI development:
|
||||
|
||||
```bash
|
||||
./scripts/build-web.sh --install
|
||||
./scripts/build-web.sh
|
||||
```
|
||||
|
||||
`package.json`, `tailwind.config.js`, and `web/tailwind.input.css` contain the local Tailwind build configuration. `node_modules/` is intentionally not packaged or committed. LXC installation/update does not require Node.js because the prebuilt `web/styles.css` ships with the release. If local Tailwind dependencies are present, `dev.sh`, `install.sh`, and `update.sh` rebuild the CSS before compiling Rust.
|
||||
|
||||
## Connecting a physical GREE device
|
||||
|
||||
@@ -139,9 +154,13 @@ Use `--skip-tests` with `install.sh` or `update.sh` only when you explicitly wan
|
||||
| `GREE_CONTROLLER_DISCOVERY_BROADCAST` | `255.255.255.255:7000` | Discovery broadcast target |
|
||||
| `GREE_CONTROLLER_GREE_INTERFACE` | empty | Optional Linux interface used for all GREE UDP traffic, e.g. `eth1` |
|
||||
| `GREE_CONTROLLER_ID` | `gree-controller` | Controller instance identifier used for logs/metadata; the GREE wire protocol uses the standard `cid=app` |
|
||||
| `GREE_CONTROLLER_HOUSE_MODE` | `cool` | Initial seasonal house mode: `cool`, `heat` or `off` |
|
||||
| `GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED` | `true` | Initial outdoor-temperature assist state |
|
||||
| `HA_URL` | empty | Optional Home Assistant URL |
|
||||
| `HA_TOKEN` | empty | Optional Home Assistant Long-Lived Access Token |
|
||||
| `HA_ENTITY_ID` | empty | Optional default HA temperature sensor |
|
||||
| `HA_ENTITY_ID` | empty | Optional default HA room-temperature sensor |
|
||||
| `HA_OUTDOOR_ENTITY_ID` | empty | Optional HA outdoor-temperature sensor |
|
||||
| `HA_ALLOW_INVALID_TLS` | `false` | Opt in to invalid/self-signed HA HTTPS certificates |
|
||||
|
||||
Settings changed from the web panel are stored in SQLite. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup. When `GREE_CONTROLLER_DISCOVERY_BROADCAST` is explicitly present in the service environment, it overrides the persisted discovery target. Use `auto` together with `GREE_CONTROLLER_GREE_INTERFACE` to derive the subnet broadcast automatically.
|
||||
|
||||
@@ -167,7 +186,17 @@ For combined control, `max_sensor_difference` protects against an obviously inco
|
||||
|
||||
The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload.
|
||||
|
||||
Zone cards also expose quick `- / +` target controls and Heat/Cool buttons. These update the zone without opening the edit dialog and immediately push the changed mode/setpoint to the paired GREE unit without forcing power ON/OFF. Active schedules may still replace the zone setpoint while their time window is active.
|
||||
Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep now / Away**, and **House / Heat / Cool** are available without opening the edit dialog. Temporary overrides automatically end at the next schedule boundary. The zone engine, not the browser, owns device commands so schedules, sensors and manual overrides cannot fight each other.
|
||||
|
||||
## Smart thermostat and schedules
|
||||
|
||||
Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. **House Off** remains an explicit hard-off mode.
|
||||
|
||||
Choose one seasonal **House mode** — Cooling in summer, Heating in winter, or Off. Zones follow the house mode by default, while a zone may explicitly override Heat/Cool when required. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating.
|
||||
|
||||
Schedules store profiles rather than duplicating temperatures. Ready-made **Family**, **Child room**, **Bedroom**, **Workday** and **Always comfort** templates generate ordinary editable schedule entries. For example, a child room template can enter Sleep earlier; if the child goes to bed even earlier, press **Sleep now** and that zone remains in Sleep until its next schedule boundary. Whole-house **Comfort all / Sleep all / Away all / Auto schedule** actions are also available.
|
||||
|
||||
The optional HA outdoor sensor is an assist signal only. It never replaces the room-control temperature. In extreme weather it can make the active device setpoint slightly more assertive and let Smart Fan increase airflow.
|
||||
|
||||
## Home Assistant
|
||||
|
||||
@@ -178,6 +207,8 @@ There are two independent HA directions:
|
||||
|
||||
For the HA client connection, open **Settings -> Home Assistant integration access** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/command API; they cannot change controller settings or manage other tokens.
|
||||
|
||||
The outbound HA sensor client also supports local HTTPS endpoints such as `https://10.87.65.2`. If the server uses a self-signed, expired or hostname-mismatched certificate, enable **Settings -> Allow invalid/self-signed HTTPS certificate**. This disables certificate/hostname validation only for the controller's outbound Home Assistant sensor client and should only be used on a trusted LAN.
|
||||
|
||||
The second option is designed to replace the built-in/default GREE integration without changing automation/dashboard references.
|
||||
|
||||
Example migration target:
|
||||
@@ -229,6 +260,10 @@ POST /api/devices/:id/poll
|
||||
POST /api/devices/:id/command
|
||||
GET /api/zones
|
||||
POST /api/zones
|
||||
POST /api/zones/:id/control
|
||||
POST /api/zones/:id/schedule-template
|
||||
POST /api/house/control
|
||||
POST /api/house/preset
|
||||
GET /api/schedules
|
||||
POST /api/schedules
|
||||
GET /api/automations
|
||||
@@ -326,3 +361,7 @@ The systemd service explicitly allows `AF_NETLINK`. This is required by Linux `g
|
||||
### v0.3.8 legacy V1 device compatibility
|
||||
|
||||
GREE protocol identifiers are now emitted as canonical lowercase hexadecimal in `tcid` and `pack.mac`. This improves compatibility with older V1 Wi-Fi modules (including devices in the `502cc6...` family) that can answer discovery but silently ignore bind/status packets when MAC casing differs from their discovery identity. Stored device IDs and friendly names are not changed.
|
||||
|
||||
### v0.4.0 smart thermostat
|
||||
|
||||
Zones now use setpoint modulation, global seasonal house mode, cooling/heating Comfort/Sleep/Away profiles, temporary per-zone overrides, ready-made editable schedule templates, whole-house preset actions and optional outdoor-temperature assist. The UI also uses theme-aware thin scrollbars and can opt in to invalid/self-signed certificates for the outbound Home Assistant sensor connection. Existing zones remain readable; a legacy zone keeps its previous setpoint as its comfort target until it is edited and saved with the new profile fields.
|
||||
|
||||
+73
-21
@@ -55,58 +55,110 @@ curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json
|
||||
|
||||
Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Physical GREE Celsius setpoints are normalized to whole degrees in the `8..30°C` range.
|
||||
|
||||
## Zone
|
||||
## Smart thermostat / house control
|
||||
|
||||
The controller separates the seasonal house mode from per-zone profiles. Normal operation uses setpoint modulation: units remain powered while the controller moves the device target between an active and satisfied setpoint. `off` is the explicit hard-off house mode.
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/house/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"mode":"cool"}'
|
||||
|
||||
curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"preset":"sleep"}'
|
||||
```
|
||||
|
||||
House modes: `cool`, `heat`, `off`. House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates temporary per-zone overrides that expire at each zone's next schedule boundary.
|
||||
|
||||
A zone stores separate profile temperatures for both seasons:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Living room",
|
||||
"name": "Paweł",
|
||||
"device_id": "gree-aabbccddeeff",
|
||||
"enabled": true,
|
||||
"mode": "cool",
|
||||
"inherit_house_mode": true,
|
||||
"setpoint": 23.0,
|
||||
"cool_comfort_setpoint": 23.0,
|
||||
"cool_sleep_setpoint": 24.5,
|
||||
"cool_away_setpoint": 27.0,
|
||||
"heat_comfort_setpoint": 21.0,
|
||||
"heat_sleep_setpoint": 19.0,
|
||||
"heat_away_setpoint": 17.0,
|
||||
"hysteresis": 0.6,
|
||||
"min_on_seconds": 180,
|
||||
"min_off_seconds": 180,
|
||||
"min_adjust_seconds": 120,
|
||||
"standby_offset_c": 2.0,
|
||||
"smart_fan": true,
|
||||
"sensor_source": "combined",
|
||||
"ha_entity_id": "sensor.living_room_temperature",
|
||||
"ha_entity_id": "sensor.pawel_temperature",
|
||||
"external_sensor_weight": 0.4,
|
||||
"max_sensor_difference": 3.0
|
||||
}
|
||||
```
|
||||
|
||||
`sensor_source` supports:
|
||||
|
||||
- `device` — GREE indoor sensor only,
|
||||
- `combined` — GREE + this zone's HA room sensor,
|
||||
- `home_assistant` — this zone's HA room sensor, with GREE fallback.
|
||||
|
||||
For `combined` and `home_assistant`, set a per-zone `ha_entity_id`. `external_sensor_weight` is `0.0..1.0`. If a combined sensor pair differs by more than `max_sensor_difference`, the controller falls back to GREE. The returned zone object includes `device_temperature`, `external_temperature`, `current_temperature`, and `control_temperature_source`.
|
||||
|
||||
### Quick zone control
|
||||
Quick control does not directly fight the schedule engine:
|
||||
|
||||
```bash
|
||||
# Sleep now until the next schedule boundary
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"setpoint":21.5,"mode":"heat"}'
|
||||
-d '{"preset":"sleep"}'
|
||||
|
||||
# Return to automatic schedule
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"preset":"auto"}'
|
||||
|
||||
# Temporary custom room target
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"setpoint":22.5}'
|
||||
```
|
||||
|
||||
Fields are optional: `setpoint`, `mode` (`heat`/`cool`) and `enabled`. The zone setpoint may use 0.5°C precision for controller hysteresis; when the quick control changes setpoint/mode, the paired GREE unit is updated immediately while its current power state is preserved.
|
||||
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. Global `off` always wins over local zone mode overrides.
|
||||
|
||||
## Schedule
|
||||
### Ready-made schedule templates
|
||||
|
||||
Weekdays use ISO numbers: Monday `1`, Sunday `7`.
|
||||
Templates generate normal schedule records and replace the current schedules for the selected zone. Every generated entry remains editable.
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/schedule-template" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"template":"child"}'
|
||||
```
|
||||
|
||||
Templates: `family`, `child`, `bedroom`, `workday`, `always`.
|
||||
|
||||
Schedules use a profile (`comfort`, `sleep`, `away`) or a custom temperature:
|
||||
|
||||
```json
|
||||
{
|
||||
"zone_id": "UUID",
|
||||
"name": "Night",
|
||||
"name": "Sleep",
|
||||
"enabled": true,
|
||||
"weekdays": [1,2,3,4,5,6,7],
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
"start_time": "20:30",
|
||||
"end_time": "06:30",
|
||||
"preset": "sleep",
|
||||
"setpoint": 24.0
|
||||
}
|
||||
```
|
||||
|
||||
For non-`custom` schedules, `setpoint` is only a compatibility value; the actual target comes from the zone's seasonal profile.
|
||||
|
||||
### Home Assistant outdoor assist and local HTTPS
|
||||
|
||||
Runtime settings can specify `home_assistant.outdoor_entity_id` and `outdoor_assist_enabled`. Outdoor temperature never replaces the zone room temperature. It is only used to make active setpoints/fan speed slightly more assertive in extreme weather.
|
||||
|
||||
For trusted local Home Assistant servers with self-signed/invalid HTTPS certificates, set:
|
||||
|
||||
```json
|
||||
{
|
||||
"home_assistant": {
|
||||
"url": "https://10.87.65.2",
|
||||
"allow_invalid_tls": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This setting is opt-in and applies only to the controller's outbound Home Assistant sensor client.
|
||||
|
||||
## WebSocket
|
||||
|
||||
Connect to `ws://HOST:8787/ws?token=TOKEN`. The first message uses event type `bootstrap`; later events include `device.updated`, `zone.updated`, `settings.updated` and `log.created`.
|
||||
|
||||
@@ -168,3 +168,10 @@ For a dedicated GREE NIC such as `eth1`, the expected startup/bind log should id
|
||||
### Legacy V1 devices discovered but not binding
|
||||
|
||||
Version 0.3.8 sends the GREE protocol `tcid` and inner `mac` identifiers in canonical lowercase hexadecimal. If an older `502cc6...` device is discovered on UDP/7000 but stays offline after bind timeouts, update to v0.3.8 before changing routing or firewall settings. With debug logging, `Sending GREE request` should show `wire_mac=502cc6...` in lowercase.
|
||||
|
||||
|
||||
## Home Assistant HTTPS with a self-signed certificate
|
||||
|
||||
The optional outbound HA sensor client validates TLS certificates by default. For a trusted local endpoint such as `https://10.87.65.2` that uses a self-signed, expired or hostname-mismatched certificate, enable **Settings -> Allow invalid/self-signed HTTPS certificate** and save. The same initial setting can be supplied as `HA_ALLOW_INVALID_TLS=true`.
|
||||
|
||||
This opt-in affects only controller -> Home Assistant sensor requests. It does not change the GREE UDP transport and it does not add HTTPS termination to the controller itself.
|
||||
|
||||
+32
-12
@@ -66,23 +66,35 @@ Each device stores:
|
||||
- fan, swing, quiet, turbo, light,
|
||||
- online/last-seen/error state.
|
||||
|
||||
## Temperature zones
|
||||
## Smart thermostat, zones and schedules
|
||||
|
||||
A zone connects an AC to a temperature-control policy. It contains:
|
||||
The primary user model is a fast thermostat, not raw device automation. A global **House mode** selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. Global Off is authoritative and powers controlled units down.
|
||||
|
||||
- target setpoint,
|
||||
- heat/cool mode,
|
||||
- hysteresis,
|
||||
- minimum ON time,
|
||||
- minimum OFF time,
|
||||
- temperature source,
|
||||
- optional Home Assistant sensor entity.
|
||||
During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**:
|
||||
|
||||
Each zone can assign its own Home Assistant room sensor. Combined mode fuses the GREE and room measurements with a configurable weight (40% room sensor by default), while GREE remains the primary input. If HA is unavailable or the two sensors differ beyond the configured limit, control falls back to the GREE sensor.
|
||||
- when the zone requires conditioning, use the active target (optionally assisted slightly by outdoor weather),
|
||||
- when the zone is satisfied, move the device setpoint to the non-demand side of the room target so the inverter/compressor can stop naturally,
|
||||
- use hysteresis and a minimum adjustment interval to avoid command chatter,
|
||||
- optionally adjust fan speed based on room error and outdoor extremes.
|
||||
|
||||
Each zone has separate seasonal profile temperatures:
|
||||
|
||||
- Cooling: Comfort / Sleep / Away,
|
||||
- Heating: Comfort / Sleep / Away.
|
||||
|
||||
The zone can use the GREE internal sensor, its own Home Assistant room sensor, or a weighted combination. External room sensors are configured per zone; loss/discrepancy falls back safely to the GREE sensor.
|
||||
|
||||
Weekly schedules reference profiles instead of duplicating temperatures. Ready-made Family, Child room, Bedroom, Workday and Always-comfort templates create ordinary schedule rows that remain fully editable. A manual **Sleep now**, Comfort, Away or custom-temperature override ends automatically at the next schedule boundary. Whole-house preset actions apply the same temporary policy to every zone.
|
||||
|
||||
Automations remain available for advanced exceptions. Daily comfort should be implemented with zones/profiles/schedules so direct-device automation commands do not compete with the thermostat engine.
|
||||
|
||||
### Outdoor temperature assist
|
||||
|
||||
A configured Home Assistant outdoor-temperature entity is optional auxiliary context. It never replaces the room-control temperature. Under strong heat/cold the engine may slightly bias the active AC target and increase Smart Fan airflow. If HA is unavailable, the smart thermostat continues without outdoor assistance.
|
||||
|
||||
## Scheduler and automations
|
||||
|
||||
Schedules are weekly time windows and may cross midnight. Automations currently support time or temperature triggers and send device commands with cooldown protection.
|
||||
Schedules are weekly time windows and may cross midnight. Schedules can select Comfort/Sleep/Away or a custom setpoint. Automations support time or temperature triggers and device commands with cooldown protection; they are intended for exceptions rather than the normal daily thermostat cycle.
|
||||
|
||||
The automation engine runs in Rust and does not require Home Assistant YAML automation logic.
|
||||
|
||||
@@ -142,7 +154,8 @@ A development script prepares dependencies/build/runtime configuration, and a se
|
||||
- secrets kept outside source control,
|
||||
- no direct public Internet exposure,
|
||||
- TLS delegated to a trusted reverse proxy/VPN when required,
|
||||
- local device keys and HA token protected in environment/database files.
|
||||
- local device keys and HA token protected in environment/database files,
|
||||
- invalid/self-signed HA HTTPS certificates are rejected by default; bypass is an explicit trusted-LAN opt-in.
|
||||
|
||||
|
||||
## Per-zone room sensors
|
||||
@@ -160,3 +173,10 @@ The zone controller supports `device`, `combined`, and `home_assistant` temperat
|
||||
- `scripts/service.sh` provides common systemd operations.
|
||||
- Cargo's `build.rs` stays at the package root because Cargo requires that location.
|
||||
- Every SQLite statement and schema definition is centralized in `src/queries.rs`; database/domain code must not embed SQL strings elsewhere.
|
||||
|
||||
|
||||
## Web UI design system (v0.4.1)
|
||||
|
||||
The embedded UI uses a compact Supabase-inspired dashboard language while retaining GREE Controller branding and information architecture. Light, Dark and System themes are driven by CSS design tokens. The visual system favors neutral layered surfaces, thin borders, 6-8 px radii, compact controls, a restrained green accent, no decorative shadows, a desktop sidebar and mobile bottom navigation.
|
||||
|
||||
Tailwind CSS is an optional local build dependency only. `web/styles.css` is committed and embedded into the Rust binary, so production/LXC runtime has no CDN or Node.js dependency. UI source/build files are `web/tailwind.input.css`, `tailwind.config.js`, `package.json`, and `scripts/build-web.sh`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.3.8",
|
||||
"version": "0.4.1",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
+51
-2
@@ -75,7 +75,7 @@
|
||||
"devices.added": "Device added",
|
||||
"zones.automation": "Automation",
|
||||
"zones.new": "New zone",
|
||||
"zones.description": "A zone controls a device using temperature, hysteresis and minimum cycle time.",
|
||||
"zones.description": "Each zone is a fast thermostat. It normally follows the house heating/cooling mode and changes the AC setpoint instead of cycling power.",
|
||||
"zones.hysteresis": "Hysteresis °C",
|
||||
"zones.source": "Temperature strategy",
|
||||
"zones.greeSensor": "GREE only",
|
||||
@@ -238,6 +238,55 @@
|
||||
"zones.quickHint": "Quick target and mode control",
|
||||
"actions.later": "Later",
|
||||
"discovery.nameDevices": "Name discovered devices",
|
||||
"discovery.nameDevicesHelp": "Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics."
|
||||
"discovery.nameDevicesHelp": "Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics.",
|
||||
"dashboard.quickThermostats": "Quick thermostats",
|
||||
"common.unavailable": "Unavailable",
|
||||
"mode.off": "Off",
|
||||
"house.seasonMode": "Home climate mode",
|
||||
"house.smartThermostat": "Smart thermostat",
|
||||
"house.setpointStrategy": "Keeps units running and modulates their target temperature instead of repeatedly switching power.",
|
||||
"house.outdoor": "Outdoor",
|
||||
"house.modeUpdated": "Home climate mode updated",
|
||||
"zones.modePolicy": "Mode policy",
|
||||
"zones.followHouse": "Follow house mode",
|
||||
"zones.followHouseShort": "House",
|
||||
"zones.profileTemperatures": "Comfort / sleep / away temperatures",
|
||||
"zones.minAdjust": "Minimum adjustment interval (s)",
|
||||
"zones.standbyOffset": "Standby setpoint offset °C",
|
||||
"zones.smartFan": "Smart fan assist",
|
||||
"zones.deviceTarget": "AC target",
|
||||
"zones.overrideUntil": "Manual until",
|
||||
"zones.scheduleControl": "Schedule control",
|
||||
"zones.requesting": "Comfort demand",
|
||||
"zones.satisfied": "Satisfied / inverter standby",
|
||||
"zones.quickHintSmart": "Quick changes override the schedule until its next transition.",
|
||||
"preset.auto": "Auto",
|
||||
"preset.comfort": "Comfort",
|
||||
"preset.sleep": "Sleep",
|
||||
"preset.away": "Away",
|
||||
"preset.custom": "Custom",
|
||||
"schedules.quickSetup": "Quick schedule setup",
|
||||
"schedules.quickSetupHint": "Choose a zone and a ready-made daily rhythm. You can edit every generated entry afterwards.",
|
||||
"schedules.templateFamily": "Family",
|
||||
"schedules.templateChild": "Child room",
|
||||
"schedules.templateBedroom": "Bedroom",
|
||||
"schedules.templateWorkday": "Workday",
|
||||
"schedules.templateAlways": "Always comfort",
|
||||
"schedules.chooseZone": "Choose a zone first",
|
||||
"schedules.templateApplied": "Schedule preset applied",
|
||||
"schedules.profile": "Profile",
|
||||
"settings.outdoorEntity": "Outdoor temperature entity_id",
|
||||
"settings.outdoorAssist": "Use outdoor temperature as smart-control assist",
|
||||
"settings.allowInvalidTls": "Allow invalid/self-signed HTTPS certificate",
|
||||
"settings.allowInvalidTlsHint": "Use only for a trusted local Home Assistant server, for example https://10.87.65.2.",
|
||||
"zones.sleepNow": "Sleep now",
|
||||
"house.autoAll": "Auto schedule",
|
||||
"house.comfortAll": "Comfort all",
|
||||
"house.sleepAll": "Sleep all",
|
||||
"house.awayAll": "Away all",
|
||||
"house.presetUpdated": "Home preset updated",
|
||||
"schedules.replaceConfirm": "Replace the current schedule for this zone with the selected preset? You can edit every generated entry afterwards.",
|
||||
"automations.smartHintTitle": "Use thermostat presets for daily comfort",
|
||||
"automations.smartHint": "Schedules and zone presets handle normal heating/cooling automatically. Keep automations for special exceptions so they do not fight the thermostat."
|
||||
}
|
||||
}
|
||||
|
||||
+51
-2
@@ -75,7 +75,7 @@
|
||||
"devices.added": "Urządzenie dodane",
|
||||
"zones.automation": "Automatyka",
|
||||
"zones.new": "Nowa strefa",
|
||||
"zones.description": "Strefa steruje urządzeniem według temperatury, histerezy i minimalnego czasu cyklu.",
|
||||
"zones.description": "Każda strefa jest szybkim termostatem. Zwykle dziedziczy tryb grzania/chłodzenia domu i zmienia setpoint klimatyzatora zamiast cyklicznie wyłączać zasilanie.",
|
||||
"zones.hysteresis": "Histereza °C",
|
||||
"zones.source": "Strategia temperatury",
|
||||
"zones.greeSensor": "Tylko GREE",
|
||||
@@ -238,6 +238,55 @@
|
||||
"zones.quickHint": "Szybka zmiana celu i trybu",
|
||||
"actions.later": "Później",
|
||||
"discovery.nameDevices": "Nazwij znalezione urządzenia",
|
||||
"discovery.nameDevicesHelp": "Nadaj każdej nowej jednostce przyjazną nazwę pokoju. Model techniczny i MAC pozostaną dostępne w diagnostyce."
|
||||
"discovery.nameDevicesHelp": "Nadaj każdej nowej jednostce przyjazną nazwę pokoju. Model techniczny i MAC pozostaną dostępne w diagnostyce.",
|
||||
"dashboard.quickThermostats": "Szybkie termostaty",
|
||||
"common.unavailable": "Niedostępne",
|
||||
"mode.off": "Wyłączone",
|
||||
"house.seasonMode": "Tryb klimatu domu",
|
||||
"house.smartThermostat": "Inteligentny termostat",
|
||||
"house.setpointStrategy": "Utrzymuje jednostki włączone i steruje temperaturą zadaną zamiast często przełączać zasilanie.",
|
||||
"house.outdoor": "Na zewnątrz",
|
||||
"house.modeUpdated": "Zmieniono tryb klimatu domu",
|
||||
"zones.modePolicy": "Polityka trybu",
|
||||
"zones.followHouse": "Dziedzicz tryb domu",
|
||||
"zones.followHouseShort": "Dom",
|
||||
"zones.profileTemperatures": "Temperatury komfort / sen / poza domem",
|
||||
"zones.minAdjust": "Minimalny odstęp regulacji (s)",
|
||||
"zones.standbyOffset": "Przesunięcie temperatury standby °C",
|
||||
"zones.smartFan": "Inteligentne wspomaganie nawiewu",
|
||||
"zones.deviceTarget": "Cel jednostki",
|
||||
"zones.overrideUntil": "Ręcznie do",
|
||||
"zones.scheduleControl": "Sterowanie harmonogramem",
|
||||
"zones.requesting": "Potrzeba komfortu",
|
||||
"zones.satisfied": "Komfort / standby inwertera",
|
||||
"zones.quickHintSmart": "Szybka zmiana obowiązuje do następnego przejścia harmonogramu.",
|
||||
"preset.auto": "Auto",
|
||||
"preset.comfort": "Komfort",
|
||||
"preset.sleep": "Sen",
|
||||
"preset.away": "Poza domem",
|
||||
"preset.custom": "Własna",
|
||||
"schedules.quickSetup": "Szybka konfiguracja harmonogramu",
|
||||
"schedules.quickSetupHint": "Wybierz strefę i gotowy rytm dnia. Każdy wygenerowany wpis możesz potem edytować.",
|
||||
"schedules.templateFamily": "Rodzina",
|
||||
"schedules.templateChild": "Pokój dziecka",
|
||||
"schedules.templateBedroom": "Sypialnia",
|
||||
"schedules.templateWorkday": "Dzień roboczy",
|
||||
"schedules.templateAlways": "Zawsze komfort",
|
||||
"schedules.chooseZone": "Najpierw wybierz strefę",
|
||||
"schedules.templateApplied": "Zastosowano preset harmonogramu",
|
||||
"schedules.profile": "Profil",
|
||||
"settings.outdoorEntity": "entity_id temperatury zewnętrznej",
|
||||
"settings.outdoorAssist": "Używaj temperatury zewnętrznej jako wsparcia inteligentnego sterowania",
|
||||
"settings.allowInvalidTls": "Zezwól na nieważny/samopodpisany certyfikat HTTPS",
|
||||
"settings.allowInvalidTlsHint": "Używaj tylko dla zaufanego lokalnego Home Assistanta, np. https://10.87.65.2.",
|
||||
"zones.sleepNow": "Sen teraz",
|
||||
"house.autoAll": "Auto harmonogram",
|
||||
"house.comfortAll": "Komfort wszędzie",
|
||||
"house.sleepAll": "Sen wszędzie",
|
||||
"house.awayAll": "Poza domem",
|
||||
"house.presetUpdated": "Zmieniono preset domu",
|
||||
"schedules.replaceConfirm": "Zastąpić bieżący harmonogram tej strefy wybranym presetem? Każdy wygenerowany wpis można później edytować.",
|
||||
"automations.smartHintTitle": "Do codziennego komfortu używaj presetów termostatu",
|
||||
"automations.smartHint": "Harmonogramy i presety stref obsługują normalne grzanie/chłodzenie automatycznie. Automatyzacje zostaw na wyjątki, aby nie walczyły z termostatem."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "gree-controller-web",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"description": "Offline Tailwind build tooling for the embedded GREE Controller Web UI",
|
||||
"scripts": {
|
||||
"build:web": "tailwindcss -c ./tailwind.config.js -i ./web/tailwind.input.css -o ./web/styles.css --minify",
|
||||
"watch:web": "tailwindcss -c ./tailwind.config.js -i ./web/tailwind.input.css -o ./web/styles.css --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "3.4.17"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ All operator-facing scripts live in this directory.
|
||||
- `update.sh` — safe in-place update with SQLite/config/binary backup and automatic rollback on failed health check.
|
||||
- `service.sh` — start, stop, restart, status, logs and health helper.
|
||||
- `dev.sh` — development build/run/check workflow.
|
||||
- `build-web.sh` — local/offline Tailwind CSS build; use `--install` once to install the development dependency.
|
||||
- `smoke.sh` — HTTP/API smoke test used by `dev.sh --check`.
|
||||
- `generate_ha_migration.py` — legacy/manual Home Assistant entity mapping helper.
|
||||
- `install-lxc.sh` — compatibility alias for `install.sh`.
|
||||
@@ -15,3 +16,5 @@ All operator-facing scripts live in this directory.
|
||||
|
||||
- `configure-gree-network.sh <interface>` — configures a dedicated GREE NIC, automatic subnet broadcast, disables the simulator, and restarts the service.
|
||||
- `network-debug.sh <interface> [broadcast-ip]` — prints interface/routing diagnostics and a tcpdump command.
|
||||
|
||||
The running controller never uses a CDN. `web/styles.css` is embedded into the Rust binary. Tailwind is only a local development/build tool.
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
INSTALL=0
|
||||
WATCH=0
|
||||
|
||||
usage() {
|
||||
cat <<'TXT'
|
||||
Build the embedded Web UI CSS with the local Tailwind CLI.
|
||||
|
||||
Usage:
|
||||
./scripts/build-web.sh
|
||||
./scripts/build-web.sh --install install npm dev dependencies first
|
||||
./scripts/build-web.sh --watch rebuild CSS while editing web files
|
||||
|
||||
The controller never loads Tailwind or any CSS from a CDN. web/styles.css is
|
||||
committed and embedded into the Rust binary, so Node.js is not required at runtime.
|
||||
TXT
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--install) INSTALL=1; shift ;;
|
||||
--watch) WATCH=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$INSTALL" -eq 1 ]]; then
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is required for --install. Install Node.js/npm first."
|
||||
say "Installing local Tailwind build dependency"
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
|
||||
TAILWIND="$PROJECT_ROOT/node_modules/.bin/tailwindcss"
|
||||
if [[ ! -x "$TAILWIND" ]]; then
|
||||
fail "Local Tailwind CLI is missing. Run: ./scripts/build-web.sh --install"
|
||||
fi
|
||||
|
||||
if [[ "$WATCH" -eq 1 ]]; then
|
||||
say "Watching the Web UI with local Tailwind"
|
||||
exec "$TAILWIND" -c ./tailwind.config.js -i ./web/tailwind.input.css -o ./web/styles.css --watch
|
||||
fi
|
||||
|
||||
say "Building the Web UI with local Tailwind"
|
||||
"$TAILWIND" -c ./tailwind.config.js -i ./web/tailwind.input.css -o ./web/styles.css --minify
|
||||
say "Generated web/styles.css"
|
||||
@@ -60,6 +60,15 @@ install_build_dependencies() {
|
||||
fi
|
||||
}
|
||||
|
||||
build_web_assets_if_available() {
|
||||
local tailwind="$PROJECT_ROOT/node_modules/.bin/tailwindcss"
|
||||
if [[ -x "$tailwind" ]]; then
|
||||
"$PROJECT_ROOT/scripts/build-web.sh"
|
||||
else
|
||||
say "Using committed offline Web UI CSS (web/styles.css)"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_rust() {
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
return 0
|
||||
|
||||
@@ -46,6 +46,8 @@ else
|
||||
command -v cargo >/dev/null 2>&1 || fail "Cargo is not installed and --no-install was requested."
|
||||
fi
|
||||
|
||||
build_web_assets_if_available
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
say "Created .env from the example configuration"
|
||||
|
||||
@@ -38,6 +38,7 @@ if [[ -x "$INSTALL_BINARY" && -f "$SERVICE_FILE" ]]; then
|
||||
fi
|
||||
install_build_dependencies
|
||||
ensure_rust
|
||||
build_web_assets_if_available
|
||||
|
||||
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||
virt="$(systemd-detect-virt --container 2>/dev/null || true)"
|
||||
@@ -74,11 +75,17 @@ GREE_CONTROLLER_AUTO_SEED=false
|
||||
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
|
||||
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
|
||||
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
|
||||
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
|
||||
GREE_CONTROLLER_GREE_INTERFACE=
|
||||
GREE_CONTROLLER_ID=gree-controller
|
||||
GREE_CONTROLLER_HOUSE_MODE=cool
|
||||
GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED=true
|
||||
RUST_LOG=info,tower_http=info
|
||||
HA_URL=
|
||||
HA_TOKEN=
|
||||
HA_ENTITY_ID=
|
||||
HA_OUTDOOR_ENTITY_ID=
|
||||
HA_ALLOW_INVALID_TLS=false
|
||||
ENV
|
||||
chmod 0600 "$ENV_FILE"
|
||||
say "Generated administrator token and saved it to $ENV_FILE"
|
||||
|
||||
@@ -40,6 +40,7 @@ require_systemd
|
||||
|
||||
install_build_dependencies
|
||||
ensure_rust
|
||||
build_web_assets_if_available
|
||||
version="$(project_version)"
|
||||
say "Preparing update to GREE Controller ${version:-unknown}"
|
||||
|
||||
|
||||
+184
-22
@@ -47,6 +47,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
|
||||
.route("/api/zones/:id/control", post(update_zone_control))
|
||||
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
|
||||
.route("/api/house/control", post(update_house_control))
|
||||
.route("/api/house/preset", post(update_house_preset))
|
||||
.route("/api/schedules", get(list_schedules).post(create_schedule))
|
||||
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
|
||||
.route("/api/automations", get(list_automations).post(create_automation))
|
||||
@@ -155,6 +158,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
"automations": state.db.list_automations()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
"settings": public_settings(&settings),
|
||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||
"system": {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
@@ -324,14 +328,34 @@ struct ZoneInput {
|
||||
enabled: bool,
|
||||
#[serde(default = "cool")]
|
||||
mode: String,
|
||||
#[serde(default = "yes")]
|
||||
inherit_house_mode: bool,
|
||||
#[serde(default = "setpoint")]
|
||||
setpoint: f64,
|
||||
#[serde(default = "cool_comfort")]
|
||||
cool_comfort_setpoint: f64,
|
||||
#[serde(default = "cool_sleep")]
|
||||
cool_sleep_setpoint: f64,
|
||||
#[serde(default = "cool_away")]
|
||||
cool_away_setpoint: f64,
|
||||
#[serde(default = "heat_comfort")]
|
||||
heat_comfort_setpoint: f64,
|
||||
#[serde(default = "heat_sleep")]
|
||||
heat_sleep_setpoint: f64,
|
||||
#[serde(default = "heat_away")]
|
||||
heat_away_setpoint: f64,
|
||||
#[serde(default = "hysteresis")]
|
||||
hysteresis: f64,
|
||||
#[serde(default = "cycle")]
|
||||
min_on_seconds: u64,
|
||||
#[serde(default = "cycle")]
|
||||
min_off_seconds: u64,
|
||||
#[serde(default = "min_adjust")]
|
||||
min_adjust_seconds: u64,
|
||||
#[serde(default = "standby_offset")]
|
||||
standby_offset_c: f64,
|
||||
#[serde(default = "yes")]
|
||||
smart_fan: bool,
|
||||
#[serde(default = "device_source")]
|
||||
sensor_source: String,
|
||||
#[serde(default)]
|
||||
@@ -344,8 +368,16 @@ struct ZoneInput {
|
||||
fn yes() -> bool { true }
|
||||
fn cool() -> String { "cool".into() }
|
||||
fn setpoint() -> f64 { 24.0 }
|
||||
fn cool_comfort() -> f64 { 23.0 }
|
||||
fn cool_sleep() -> f64 { 24.5 }
|
||||
fn cool_away() -> f64 { 27.0 }
|
||||
fn heat_comfort() -> f64 { 21.0 }
|
||||
fn heat_sleep() -> f64 { 19.0 }
|
||||
fn heat_away() -> f64 { 17.0 }
|
||||
fn hysteresis() -> f64 { 0.6 }
|
||||
fn cycle() -> u64 { 180 }
|
||||
fn min_adjust() -> u64 { 120 }
|
||||
fn standby_offset() -> f64 { 2.0 }
|
||||
fn external_sensor_weight() -> f64 { 0.4 }
|
||||
fn max_sensor_difference() -> f64 { 3.0 }
|
||||
fn device_source() -> String { "device".into() }
|
||||
@@ -353,8 +385,12 @@ fn device_source() -> String { "device".into() }
|
||||
impl ZoneInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); }
|
||||
if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||
for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint,
|
||||
self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); }
|
||||
}
|
||||
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
|
||||
if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); }
|
||||
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
|
||||
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
|
||||
if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); }
|
||||
@@ -367,11 +403,17 @@ impl ZoneInput {
|
||||
fn into_zone(self, id: String, created_at: chrono::DateTime<Utc>) -> Zone {
|
||||
Zone {
|
||||
id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled,
|
||||
mode: self.mode, setpoint: self.setpoint, hysteresis: self.hysteresis,
|
||||
min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
|
||||
mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1,
|
||||
cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint,
|
||||
cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint,
|
||||
heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint,
|
||||
hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
|
||||
min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan,
|
||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_override_until: None,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, last_action_at: None,
|
||||
created_at, updated_at: Utc::now(),
|
||||
}
|
||||
@@ -399,6 +441,12 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.external_temperature = existing.external_temperature;
|
||||
zone.current_temperature = existing.current_temperature;
|
||||
zone.control_temperature_source = existing.control_temperature_source;
|
||||
zone.active_preset = existing.active_preset;
|
||||
zone.manual_preset = existing.manual_preset;
|
||||
zone.manual_override_until = existing.manual_override_until;
|
||||
zone.effective_mode = existing.effective_mode;
|
||||
zone.effective_setpoint = existing.effective_setpoint;
|
||||
zone.device_setpoint = existing.device_setpoint;
|
||||
zone.demand = existing.demand;
|
||||
zone.last_action_at = existing.last_action_at;
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -407,36 +455,139 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
}
|
||||
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
||||
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
|
||||
if let Some(value) = patch.setpoint {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||
zone.setpoint = (value * 2.0).round() / 2.0;
|
||||
zone.manual_preset = Some("custom".into());
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
if let Some(value) = patch.mode.as_deref() {
|
||||
if !matches!(value, "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
|
||||
zone.mode = value.to_string();
|
||||
match value {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = value.to_string();
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())),
|
||||
}
|
||||
}
|
||||
if let Some(value) = patch.preset.as_deref() {
|
||||
match value {
|
||||
"auto" => {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
"comfort" | "sleep" | "away" | "custom" => {
|
||||
zone.manual_preset = Some(value.to_string());
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
||||
}
|
||||
}
|
||||
if patch.clear_override.unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
if let Some(value) = patch.enabled { zone.enabled = value; }
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
|
||||
// Quick zone controls also update the paired climate unit immediately. Power is
|
||||
// intentionally left unchanged; the zone engine still owns ON/OFF demand.
|
||||
if patch.setpoint.is_some() || patch.mode.is_some() {
|
||||
let command = DeviceCommand {
|
||||
mode: patch.mode.as_ref().map(|_| zone.mode.clone()),
|
||||
target_temperature: patch.setpoint.map(|_| zone.setpoint),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(err) = engine::send_command(&state, &zone.device_id, command).await {
|
||||
state.log("warn", "zone.quick_control_device_error", &format!("{}: {err}", zone.name), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
}
|
||||
}
|
||||
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode, "enabled": zone.enabled}));
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled
|
||||
}));
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
|
||||
}
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.house_mode = input.mode;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
let payload = public_settings(&settings);
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
state.log("info", "house.mode", &format!("House mode set to {}", settings.house_mode), json!({"mode": settings.house_mode}));
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HousePresetPatch { preset: String }
|
||||
|
||||
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
||||
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zones = state.db.list_zones()?;
|
||||
for zone in &mut zones {
|
||||
if input.preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
}
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({"preset": input.preset}));
|
||||
Ok(Json(json!({"preset": input.preset, "zones": zones})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScheduleTemplateRequest { template: String }
|
||||
|
||||
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut items: Vec<Schedule> = Vec::new();
|
||||
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||
items.push(Schedule {
|
||||
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
||||
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
||||
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
});
|
||||
};
|
||||
let all = vec![1,2,3,4,5,6,7];
|
||||
match input.template.as_str() {
|
||||
"family" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:30", "comfort");
|
||||
add("Sleep", all, "22:30", "06:30", "sleep");
|
||||
}
|
||||
"child" => {
|
||||
add("Comfort", all.clone(), "06:30", "20:30", "comfort");
|
||||
add("Sleep", all, "20:30", "06:30", "sleep");
|
||||
}
|
||||
"bedroom" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:00", "comfort");
|
||||
add("Sleep", all, "22:00", "06:30", "sleep");
|
||||
}
|
||||
"workday" => {
|
||||
let weekdays = vec![1,2,3,4,5];
|
||||
let weekend = vec![6,7];
|
||||
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
|
||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
||||
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
||||
add("Weekend", weekend.clone(), "08:00", "23:00", "comfort");
|
||||
add("Weekend sleep", weekend, "23:00", "08:00", "sleep");
|
||||
}
|
||||
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
|
||||
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
||||
}
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||
}
|
||||
|
||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
@@ -452,21 +603,25 @@ struct ScheduleInput {
|
||||
weekdays: Vec<u32>,
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
#[serde(default = "schedule_preset")]
|
||||
preset: String,
|
||||
setpoint: f64,
|
||||
}
|
||||
fn schedule_preset() -> String { "custom".into() }
|
||||
impl ScheduleInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
|
||||
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
|
||||
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
|
||||
if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
|
||||
if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); }
|
||||
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
|
||||
Ok(())
|
||||
}
|
||||
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
|
||||
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
|
||||
setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
||||
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
||||
@@ -591,6 +746,8 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
|
||||
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
|
||||
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
|
||||
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
||||
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
|
||||
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
|
||||
input.discovery_broadcast.parse::<std::net::SocketAddr>()
|
||||
@@ -674,11 +831,16 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"zone_interval_seconds": settings.zone_interval_seconds,
|
||||
"discovery_timeout_ms": settings.discovery_timeout_ms,
|
||||
"discovery_broadcast": settings.discovery_broadcast,
|
||||
"house_mode": settings.house_mode,
|
||||
"control_strategy": settings.control_strategy,
|
||||
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
|
||||
"home_assistant": {
|
||||
"url": settings.home_assistant.url,
|
||||
"token": "",
|
||||
"token_configured": !settings.home_assistant.token.trim().is_empty(),
|
||||
"default_entity_id": settings.home_assistant.default_entity_id,
|
||||
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
||||
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,10 +49,19 @@ impl Config {
|
||||
zone_interval_seconds: self.zone_interval_seconds.max(2),
|
||||
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
|
||||
discovery_broadcast: self.discovery_broadcast.clone(),
|
||||
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
|
||||
control_strategy: "setpoint".into(),
|
||||
outdoor_assist_enabled: env::var("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(true),
|
||||
home_assistant: HomeAssistantSettings {
|
||||
url: env::var("HA_URL").unwrap_or_default(),
|
||||
token: env::var("HA_TOKEN").unwrap_or_default(),
|
||||
default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(),
|
||||
outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(),
|
||||
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,21 @@ impl Db {
|
||||
self.delete_by_id("schedules", id)
|
||||
}
|
||||
|
||||
pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, item: &Automation) -> Result<()> {
|
||||
let payload = Self::to_json(item)?;
|
||||
let conn = self.lock()?;
|
||||
|
||||
+255
-34
@@ -248,15 +248,54 @@ fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
|
||||
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
|
||||
// it only makes the active setpoint/fan a little more assertive in extreme weather.
|
||||
let outdoor_temperature = if settings.outdoor_assist_enabled && !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
|
||||
match home_assistant::read_temperature(
|
||||
&state.http,
|
||||
&settings.home_assistant,
|
||||
Some(settings.home_assistant.outdoor_entity_id.trim()),
|
||||
).await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != outdoor_temperature {
|
||||
*current = outdoor_temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
||||
}
|
||||
}
|
||||
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !zone.enabled { continue; }
|
||||
if let Some(setpoint) = active_setpoint(&zone, &schedules, Local::now()) {
|
||||
zone.setpoint = setpoint;
|
||||
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
||||
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
|
||||
continue;
|
||||
};
|
||||
|
||||
let effective_mode = if settings.house_mode == "off" {
|
||||
"off"
|
||||
} else if zone.inherit_house_mode {
|
||||
settings.house_mode.as_str()
|
||||
} else {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
zone.effective_mode = effective_mode.to_string();
|
||||
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
let device_temperature = device.current_temperature;
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
@@ -290,33 +329,99 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}));
|
||||
}
|
||||
|
||||
let Some(temp) = temperature else { state.db.save_zone(&zone)?; continue; };
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
let desired = match zone.mode.as_str() {
|
||||
"heat" => if temp <= zone.setpoint - half { Some(true) } else if temp >= zone.setpoint + half { Some(false) } else { None },
|
||||
_ => if temp >= zone.setpoint + half { Some(true) } else if temp <= zone.setpoint - half { Some(false) } else { None },
|
||||
};
|
||||
if let Some(on) = desired {
|
||||
zone.demand = on;
|
||||
if on != device.power && cycle_allowed(&zone, device.power) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(on),
|
||||
mode: if on { Some(zone.mode.clone()) } else { None },
|
||||
target_temperature: if on { Some(zone.setpoint) } else { None },
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(_) => {
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "zone.action", &format!("Zone {} demand {}", zone.name, if on { "ON" } else { "OFF" }), json!({
|
||||
"zone_id": zone.id, "temperature": temp, "setpoint": zone.setpoint,
|
||||
}));
|
||||
}
|
||||
// Global Off is the only normal path that intentionally powers units down.
|
||||
if effective_mode == "off" {
|
||||
zone.active_preset = "off".into();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
if device.power {
|
||||
match send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
Ok(_) => zone.last_action_at = Some(Utc::now()),
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
let Some(temp) = temperature else {
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
};
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
zone.demand = match effective_mode {
|
||||
"heat" => {
|
||||
if temp <= target - half { true }
|
||||
else if temp >= target + half { false }
|
||||
else { zone.demand }
|
||||
}
|
||||
_ => {
|
||||
if temp >= target + half { true }
|
||||
else if temp <= target - half { false }
|
||||
else { zone.demand }
|
||||
}
|
||||
};
|
||||
|
||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||
let assist = outdoor_assist_offset(effective_mode, outdoor_temperature, temp, target);
|
||||
let active_target = match effective_mode {
|
||||
"heat" => target + assist,
|
||||
_ => target - assist,
|
||||
};
|
||||
let standby_target = match effective_mode {
|
||||
"heat" => target - zone.standby_offset_c.max(0.5),
|
||||
_ => target + zone.standby_offset_c.max(0.5),
|
||||
};
|
||||
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
||||
zone.device_setpoint = Some(desired_device_target);
|
||||
|
||||
let desired_fan = if zone.smart_fan {
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_temperature, zone.demand))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let needs_command = !device.power
|
||||
|| device.mode != effective_mode
|
||||
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|
||||
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false);
|
||||
|
||||
let urgent_mode_change = !device.power || device.mode != effective_mode;
|
||||
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(true),
|
||||
mode: Some(effective_mode.to_string()),
|
||||
target_temperature: Some(desired_device_target),
|
||||
fan_speed: desired_fan,
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(_) => {
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
"zone_id": zone.id,
|
||||
"room_temperature": temp,
|
||||
"comfort_target": target,
|
||||
"device_target": desired_device_target,
|
||||
"mode": effective_mode,
|
||||
"preset": zone.active_preset,
|
||||
"outdoor_temperature": outdoor_temperature,
|
||||
}));
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
@@ -351,17 +456,94 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, externa
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool {
|
||||
fn adjustment_allowed(zone: &Zone) -> bool {
|
||||
let Some(last) = zone.last_action_at else { return true; };
|
||||
let elapsed = (Utc::now() - last).num_seconds().max(0) as u64;
|
||||
if currently_on { elapsed >= zone.min_on_seconds } else { elapsed >= zone.min_off_seconds }
|
||||
(Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15)
|
||||
}
|
||||
|
||||
fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime<Local>) -> Option<f64> {
|
||||
fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
|
||||
let value = value.clamp(16.0, 30.0);
|
||||
match (mode, demand) {
|
||||
("heat", true) => value.ceil(),
|
||||
("heat", false) => value.floor(),
|
||||
(_, true) => value.floor(),
|
||||
(_, false) => value.ceil(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 {
|
||||
let Some(outdoor) = outdoor else { return 0.0; };
|
||||
let room_error = (room - target).abs();
|
||||
let weather = match mode {
|
||||
"heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0),
|
||||
_ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0),
|
||||
};
|
||||
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
||||
if !demand { return 0; }
|
||||
let error = (room - target).abs();
|
||||
let extreme_weather = match (mode, outdoor) {
|
||||
("heat", Some(value)) => value <= 0.0,
|
||||
(_, Some(value)) => value >= 32.0,
|
||||
_ => false,
|
||||
};
|
||||
if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 }
|
||||
}
|
||||
|
||||
fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
|
||||
if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; }
|
||||
match (mode, preset) {
|
||||
("heat", "sleep") => zone.heat_sleep_setpoint,
|
||||
("heat", "away") => zone.heat_away_setpoint,
|
||||
("heat", _) => zone.heat_comfort_setpoint,
|
||||
(_, "sleep") => zone.cool_sleep_setpoint,
|
||||
(_, "away") => zone.cool_away_setpoint,
|
||||
(_, _) => zone.cool_comfort_setpoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
|
||||
if let Some(manual) = zone.manual_preset.as_deref() {
|
||||
return if manual == "custom" {
|
||||
("custom".into(), zone.setpoint)
|
||||
} else {
|
||||
(manual.to_string(), profile_setpoint(zone, manual, mode))
|
||||
};
|
||||
}
|
||||
if let Some(item) = schedule {
|
||||
return if item.preset == "custom" {
|
||||
("custom".into(), item.setpoint)
|
||||
} else {
|
||||
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
|
||||
};
|
||||
}
|
||||
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
||||
}
|
||||
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
.last()
|
||||
.map(|item| item.setpoint)
|
||||
}
|
||||
|
||||
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> DateTime<Utc> {
|
||||
let current = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
||||
.last()
|
||||
.map(|item| item.id.as_str());
|
||||
for minute in 1..=(48 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let next = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
||||
.last()
|
||||
.map(|item| item.id.as_str());
|
||||
if next != current {
|
||||
return candidate.with_timezone(&Utc);
|
||||
}
|
||||
}
|
||||
(now + chrono::Duration::hours(8)).with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
@@ -440,7 +622,7 @@ mod tests {
|
||||
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
|
||||
let item = Schedule {
|
||||
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), setpoint: 20.0,
|
||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(schedule_active(&item, now));
|
||||
@@ -449,11 +631,15 @@ mod tests {
|
||||
fn test_zone(source: &str) -> Zone {
|
||||
Zone {
|
||||
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
||||
mode: "heat".into(), setpoint: 21.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180,
|
||||
mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1,
|
||||
cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0,
|
||||
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
|
||||
hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), demand: false, last_action_at: None,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,4 +670,39 @@ mod tests {
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonal_profiles_resolve_independently() {
|
||||
let zone = test_zone("device");
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0);
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5);
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0);
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_zone_keeps_old_comfort_setpoint() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.profile_version = 0;
|
||||
zone.setpoint = 22.5;
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5);
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_setpoint_rounding_preserves_control_direction() {
|
||||
assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0);
|
||||
assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0);
|
||||
assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0);
|
||||
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
||||
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
||||
let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0);
|
||||
assert!(cool > 0.0 && cool <= 1.0);
|
||||
assert!(heat > 0.0 && heat <= 1.0);
|
||||
assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,10 +1,24 @@
|
||||
use std::time::Duration;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use crate::models::HomeAssistantSettings;
|
||||
|
||||
fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSettings) -> Result<reqwest::Client> {
|
||||
if !settings.allow_invalid_tls {
|
||||
return Ok(default_client.clone());
|
||||
}
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.context("cannot build Home Assistant HTTPS client")
|
||||
}
|
||||
|
||||
pub async fn read_temperature(
|
||||
client: &reqwest::Client,
|
||||
default_client: &reqwest::Client,
|
||||
settings: &HomeAssistantSettings,
|
||||
entity_override: Option<&str>,
|
||||
) -> Result<f64> {
|
||||
@@ -19,6 +33,7 @@ pub async fn read_temperature(
|
||||
let path = format!("api/states/{}", entity.trim_start_matches('/'));
|
||||
base = base.join(&path).context("cannot build Home Assistant API URL")?;
|
||||
|
||||
let client = request_client(default_client, settings)?;
|
||||
let response = client.get(base)
|
||||
.bearer_auth(settings.token.trim())
|
||||
.header("Accept", "application/json")
|
||||
|
||||
@@ -58,6 +58,7 @@ async fn main() -> Result<()> {
|
||||
),
|
||||
events,
|
||||
http,
|
||||
outdoor_temperature: Arc::new(RwLock::new(None)),
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,18 @@ fn default_max_sensor_difference() -> f64 { 3.0 }
|
||||
fn default_control_temperature_source() -> String { "device".into() }
|
||||
fn default_min_cycle() -> u64 { 180 }
|
||||
fn default_cooldown() -> u64 { 300 }
|
||||
fn default_house_mode() -> String { "cool".into() }
|
||||
fn default_control_strategy() -> String { "setpoint".into() }
|
||||
fn default_standby_offset() -> f64 { 2.0 }
|
||||
fn default_min_adjust() -> u64 { 120 }
|
||||
fn default_schedule_preset() -> String { "custom".into() }
|
||||
fn default_active_preset() -> String { "comfort".into() }
|
||||
fn default_cool_comfort() -> f64 { 23.0 }
|
||||
fn default_cool_sleep() -> f64 { 24.5 }
|
||||
fn default_cool_away() -> f64 { 27.0 }
|
||||
fn default_heat_comfort() -> f64 { 21.0 }
|
||||
fn default_heat_sleep() -> f64 { 19.0 }
|
||||
fn default_heat_away() -> f64 { 17.0 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -155,14 +167,42 @@ pub struct Zone {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
/// When true, the zone follows the global house heating/cooling mode.
|
||||
#[serde(default = "default_true")]
|
||||
pub inherit_house_mode: bool,
|
||||
/// Manual/custom target retained for quick thermostat overrides.
|
||||
#[serde(default = "default_target")]
|
||||
pub setpoint: f64,
|
||||
/// 0 means a zone created before smart profiles; its legacy setpoint remains the comfort target until edited.
|
||||
#[serde(default)]
|
||||
pub profile_version: u8,
|
||||
#[serde(default = "default_cool_comfort")]
|
||||
pub cool_comfort_setpoint: f64,
|
||||
#[serde(default = "default_cool_sleep")]
|
||||
pub cool_sleep_setpoint: f64,
|
||||
#[serde(default = "default_cool_away")]
|
||||
pub cool_away_setpoint: f64,
|
||||
#[serde(default = "default_heat_comfort")]
|
||||
pub heat_comfort_setpoint: f64,
|
||||
#[serde(default = "default_heat_sleep")]
|
||||
pub heat_sleep_setpoint: f64,
|
||||
#[serde(default = "default_heat_away")]
|
||||
pub heat_away_setpoint: f64,
|
||||
#[serde(default = "default_hysteresis")]
|
||||
pub hysteresis: f64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_on_seconds: u64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_off_seconds: u64,
|
||||
/// Minimum interval between automatic setpoint/fan adjustments.
|
||||
#[serde(default = "default_min_adjust")]
|
||||
pub min_adjust_seconds: u64,
|
||||
/// Difference applied to the AC setpoint while the room is satisfied.
|
||||
#[serde(default = "default_standby_offset")]
|
||||
pub standby_offset_c: f64,
|
||||
/// Let the controller adjust fan speed based on demand and outdoor conditions.
|
||||
#[serde(default = "default_true")]
|
||||
pub smart_fan: bool,
|
||||
#[serde(default = "default_sensor_source")]
|
||||
pub sensor_source: String,
|
||||
#[serde(default)]
|
||||
@@ -185,6 +225,20 @@ pub struct Zone {
|
||||
/// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`.
|
||||
#[serde(default = "default_control_temperature_source")]
|
||||
pub control_temperature_source: String,
|
||||
/// Effective profile currently used by the zone: comfort/sleep/away/custom.
|
||||
#[serde(default = "default_active_preset")]
|
||||
pub active_preset: String,
|
||||
/// Optional user override. Cleared automatically at the next schedule boundary.
|
||||
#[serde(default)]
|
||||
pub manual_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub effective_mode: String,
|
||||
#[serde(default)]
|
||||
pub effective_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub device_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub demand: bool,
|
||||
#[serde(default)]
|
||||
@@ -204,6 +258,11 @@ pub struct ZoneControlPatch {
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// `auto` clears the override; comfort/sleep/away/custom create a temporary override.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -219,6 +278,9 @@ pub struct Schedule {
|
||||
pub start_time: String,
|
||||
/// Local time HH:MM. Ranges crossing midnight are supported.
|
||||
pub end_time: String,
|
||||
/// comfort/sleep/away/custom. Non-custom profiles resolve their target from the zone.
|
||||
#[serde(default = "default_schedule_preset")]
|
||||
pub preset: String,
|
||||
pub setpoint: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -279,6 +341,12 @@ pub struct HomeAssistantSettings {
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub default_entity_id: String,
|
||||
/// Optional outdoor temperature sensor used only as an assist signal.
|
||||
#[serde(default)]
|
||||
pub outdoor_entity_id: String,
|
||||
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
|
||||
#[serde(default)]
|
||||
pub allow_invalid_tls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -289,6 +357,14 @@ pub struct RuntimeSettings {
|
||||
pub zone_interval_seconds: u64,
|
||||
pub discovery_timeout_ms: u64,
|
||||
pub discovery_broadcast: String,
|
||||
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off.
|
||||
#[serde(default = "default_house_mode")]
|
||||
pub house_mode: String,
|
||||
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
|
||||
#[serde(default = "default_control_strategy")]
|
||||
pub control_strategy: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub outdoor_assist_enabled: bool,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct AppState {
|
||||
pub gree: GreeClient,
|
||||
pub events: broadcast::Sender<ApiEvent>,
|
||||
pub http: reqwest::Client,
|
||||
pub outdoor_temperature: Arc<RwLock<Option<f64>>>,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./web/index.html', './web/app.js'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'var(--bg)',
|
||||
surface: 'var(--surface)',
|
||||
'surface-2': 'var(--surface-2)',
|
||||
foreground: 'var(--text)',
|
||||
muted: 'var(--muted)',
|
||||
border: 'var(--line)',
|
||||
brand: 'var(--accent)',
|
||||
danger: 'var(--danger)',
|
||||
warning: 'var(--warning)'
|
||||
},
|
||||
borderRadius: {
|
||||
ui: 'var(--radius)',
|
||||
control: 'var(--radius-sm)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
+123
-38
@@ -13,7 +13,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {},
|
||||
devices: [], zones: [], 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: {},
|
||||
@@ -39,7 +39,7 @@ function applyTheme() {
|
||||
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
const meta = $('#themeColorMeta');
|
||||
if (meta) meta.content = resolved === 'light' ? '#f5f7f6' : '#0a1110';
|
||||
if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515';
|
||||
const select = $('#themeSelect');
|
||||
if (select) select.value = app.theme;
|
||||
drawCurrentChartIfVisible();
|
||||
@@ -166,6 +166,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = data.settings || null;
|
||||
app.system = data.system || {};
|
||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||
renderAll();
|
||||
if (showMessage) toast(tr('common.updated'));
|
||||
if ($('#tokenDialog').open) $('#tokenDialog').close();
|
||||
@@ -179,6 +180,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderHouseClimate();
|
||||
renderDevices();
|
||||
renderZones();
|
||||
renderSchedules();
|
||||
@@ -206,6 +208,19 @@ function renderSummary() {
|
||||
].map(([label,value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
|
||||
}
|
||||
|
||||
function renderHouseClimate() {
|
||||
const node = $('#houseClimate'); if (!node || !app.settings) return;
|
||||
const mode = app.settings.house_mode || 'cool';
|
||||
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
||||
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
|
||||
<div class="house-mode-row">
|
||||
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool"><span class="mode-indicator"></span>${esc(tr('mode.cool'))}</button>
|
||||
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat"><span class="mode-indicator"></span>${esc(tr('mode.heat'))}</button>
|
||||
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off"><span class="mode-indicator"></span>${esc(tr('mode.off'))}</button>
|
||||
</div>
|
||||
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function deviceCard(device, detailed = false) {
|
||||
const modes = ['auto','cool','dry','fan','heat'];
|
||||
const fans = [0,1,3,5];
|
||||
@@ -257,24 +272,38 @@ function zoneControlSourceLabel(source) {
|
||||
})[source] || source || tr('zones.sourceUnavailable');
|
||||
}
|
||||
|
||||
function zonePresetLabel(preset) {
|
||||
const key = `preset.${preset || 'comfort'}`;
|
||||
return tr(key) === key ? (preset || 'comfort') : tr(key);
|
||||
}
|
||||
|
||||
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 target = Number(zone.effective_setpoint ?? zone.setpoint);
|
||||
const manual = zone.manual_preset || 'auto';
|
||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||
const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl');
|
||||
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''}">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||||
<div class="preset-row">
|
||||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||||
</div>
|
||||
<div class="mode-row zone-mode-row"><button class="${mode==='house'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house">${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="zone-state-line"><span>${zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied'))}</span><span>${esc(override)}</span></div>
|
||||
${detailed ? `<div class="sensor-detail">${esc(sensorDetails)}</div><div class="card-footer"><small>${esc(tr('zones.quickHintSmart'))}</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderZones() {
|
||||
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => {
|
||||
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)}`;
|
||||
return `<article class="list-card">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(zone.setpoint)}</strong></div><div class="card-stat"><small>${esc(tr('zones.demand'))}</small><strong>${esc(tr(zone.demand ? 'common.on' : 'common.off'))}</strong></div></div>
|
||||
<div class="zone-quick-control">
|
||||
<div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number(zone.setpoint).toFixed(1)}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div>
|
||||
<div class="mode-row"><button class="${zone.mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${zone.mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
</div>
|
||||
<div class="sensor-detail">${esc(sensorDetails)}</div>
|
||||
<div class="card-footer"><small>${esc(tr('zones.quickHint'))}</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||||
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||||
$('#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;
|
||||
}
|
||||
|
||||
function renderSchedules() {
|
||||
@@ -283,7 +312,7 @@ function renderSchedules() {
|
||||
const zone = app.zones.find(z => z.id === item.zone_id);
|
||||
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(item.setpoint)}</strong></div></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
||||
}
|
||||
@@ -309,6 +338,8 @@ function fillSelects() {
|
||||
});
|
||||
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 history = $('#historyDevice');
|
||||
if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; }
|
||||
}
|
||||
@@ -336,6 +367,9 @@ function renderSettings() {
|
||||
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_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||
}
|
||||
|
||||
@@ -414,22 +448,36 @@ function updateZoneSensorFields() {
|
||||
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 populateZone(id) {
|
||||
const item = app.zones.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#zoneForm'); form.reset(); fillSelects();
|
||||
Object.entries(item).forEach(([key,value]) => { if (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');
|
||||
}
|
||||
|
||||
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'].forEach(key => form.elements[key].value = item[key]);
|
||||
['id','name','zone_id','start_time','end_time','setpoint','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
form.enabled.checked = item.enabled;
|
||||
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
|
||||
openDialog('scheduleDialog');
|
||||
updateSchedulePresetField(); openDialog('scheduleDialog');
|
||||
}
|
||||
|
||||
function populateAutomation(id) {
|
||||
@@ -510,14 +558,15 @@ function connectWebSocket() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; renderAll(); return;
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); }
|
||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); }
|
||||
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(); renderZones(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); }
|
||||
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(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); }
|
||||
else if (message.event === 'log.created' && app.currentView === 'logs') loadLogs();
|
||||
} catch (_) {}
|
||||
};
|
||||
@@ -530,8 +579,18 @@ document.addEventListener('click', async event => {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.go) { $('#moreDialog').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') updateZoneSensorFields(); openDialog(button.dataset.open); return; }
|
||||
if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); openDialog(button.dataset.open); return; }
|
||||
if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); 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 (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
|
||||
@@ -543,8 +602,17 @@ document.addEventListener('click', async event => {
|
||||
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 === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) return sendZoneControl(zone.id,{setpoint:clamp(Number(zone.setpoint)+Number(button.dataset.delta),8,30)}); }
|
||||
if (action === 'house-mode') {
|
||||
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); toast(tr('house.modeUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
if (action === 'house-preset') {
|
||||
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); toast(tr('house.presetUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.effective_setpoint ?? zone.setpoint); return sendZoneControl(zone.id,{setpoint:clamp(base+Number(button.dataset.delta),8,30)}); } }
|
||||
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 === '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);
|
||||
@@ -576,6 +644,7 @@ $('#logsRefresh').addEventListener('click', loadLogs);
|
||||
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
||||
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
|
||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||
|
||||
$('#tokenForm').addEventListener('submit', event => {
|
||||
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
|
||||
@@ -621,7 +690,15 @@ $('#deviceForm').addEventListener('submit', async event => {
|
||||
|
||||
$('#zoneForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id; const body={name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,mode:raw.mode,setpoint:Number(raw.setpoint),hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:Number(raw.max_sensor_difference)};
|
||||
const id=raw.id; const body={
|
||||
name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,
|
||||
mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:Number(raw.setpoint),
|
||||
cool_comfort_setpoint:Number(raw.cool_comfort_setpoint),cool_sleep_setpoint:Number(raw.cool_sleep_setpoint),cool_away_setpoint:Number(raw.cool_away_setpoint),
|
||||
heat_comfort_setpoint:Number(raw.heat_comfort_setpoint),heat_sleep_setpoint:Number(raw.heat_sleep_setpoint),heat_away_setpoint:Number(raw.heat_away_setpoint),
|
||||
hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),
|
||||
min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:Number(raw.standby_offset_c),smart_fan:form.smart_fan.checked,
|
||||
sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:Number(raw.max_sensor_difference)
|
||||
};
|
||||
try { await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
@@ -629,7 +706,7 @@ $('#zoneForm').addEventListener('submit', async event => {
|
||||
$('#scheduleForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
|
||||
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,setpoint:Number(raw.setpoint)};
|
||||
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:Number(raw.setpoint||23)};
|
||||
try { await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
@@ -642,11 +719,17 @@ $('#automationForm').addEventListener('submit', async event => {
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
function settingsBodyFromForm(form) {
|
||||
const raw=Object.fromEntries(new FormData(form));
|
||||
return {controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,house_mode:app.settings?.house_mode||'cool',control_strategy:'setpoint',outdoor_assist_enabled:form.outdoor_assist_enabled.checked,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,allow_invalid_tls:form.ha_allow_invalid_tls.checked}};
|
||||
}
|
||||
|
||||
async function saveSettingsForm(form, notify=true) {
|
||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||
}
|
||||
|
||||
$('#settingsForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const body={controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id}};
|
||||
try { app.settings=await api('/api/settings',{method:'PUT',body}); renderSettings(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#createAccessToken').addEventListener('click', async event => {
|
||||
@@ -677,11 +760,12 @@ $('#copyAccessToken').addEventListener('click', async () => {
|
||||
|
||||
$('#haTest').addEventListener('click', async () => {
|
||||
const form=$('#settingsForm');
|
||||
if (form.ha_token.value || form.ha_url.value !== (app.settings?.home_assistant?.url || '')) {
|
||||
form.requestSubmit(); await new Promise(resolve=>setTimeout(resolve,250));
|
||||
}
|
||||
try { const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:form.ha_entity_id.value||null}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); }
|
||||
catch(error){toast(error.message,true);}
|
||||
try {
|
||||
await saveSettingsForm(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}});
|
||||
toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)}));
|
||||
} catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); });
|
||||
@@ -693,6 +777,7 @@ async function startApplication() {
|
||||
await loadLanguages();
|
||||
applyTranslations();
|
||||
updateZoneSensorFields();
|
||||
updateSchedulePresetField();
|
||||
await loadBootstrap();
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="30" fill="#0d1715"/>
|
||||
<path d="M64 25v43" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M42 42a39 39 0 1 0 44 0" fill="none" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
|
||||
<rect width="128" height="128" rx="30" fill="#151515"/>
|
||||
<path d="M64 25v43" stroke="#3ecf8e" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M42 42a39 39 0 1 0 44 0" fill="none" stroke="#3ecf8e" stroke-width="14" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
+43
-13
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a1110" id="themeColorMeta">
|
||||
<meta name="theme-color" content="#151515" id="themeColorMeta">
|
||||
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
@@ -34,7 +34,7 @@
|
||||
<option value="light" data-i18n="theme.light">Light</option>
|
||||
<option value="dark" data-i18n="theme.dark">Dark</option>
|
||||
</select>
|
||||
<button class="icon-button" id="refreshButton" data-i18n-title="actions.refresh" data-i18n-aria="actions.refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
<button class="icon-button" id="refreshButton" data-i18n-title="actions.refresh" data-i18n-aria="actions.refresh" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66"/><path d="M20 4v7h-7"/></svg></button>
|
||||
<button class="primary compact" id="discoverButton" data-i18n="actions.discover">Discover</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -46,7 +46,10 @@
|
||||
<div class="hero-temp" id="heroTemperature">--<small>°C</small></div>
|
||||
</div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Quick control</h2></div></div>
|
||||
<div class="panel house-climate" id="houseClimate"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div>
|
||||
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Direct device control</h2></div></div>
|
||||
<div class="device-grid" id="dashboardDevices"></div>
|
||||
</section>
|
||||
|
||||
@@ -60,17 +63,29 @@
|
||||
|
||||
<section class="view" data-view="zones">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div>
|
||||
<p class="lead" data-i18n="zones.description">A zone controls a device using temperature, hysteresis and minimum cycle time.</p>
|
||||
<p class="lead" data-i18n="zones.description">Each zone is a fast thermostat. It normally follows the house heating/cooling mode and changes the AC setpoint instead of cycling power.</p>
|
||||
<div class="list-grid" id="zoneList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="schedules">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span><h1 data-i18n="nav.schedules">Schedules</h1></div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button></div>
|
||||
<div class="panel schedule-template-panel">
|
||||
<div><h3 data-i18n="schedules.quickSetup">Quick schedule setup</h3><p class="field-note" data-i18n="schedules.quickSetupHint">Choose a zone and a ready-made daily rhythm. You can edit every generated entry afterwards.</p></div>
|
||||
<select id="schedulePresetZone"></select>
|
||||
<div class="template-grid">
|
||||
<button class="secondary" data-schedule-template="family" data-i18n="schedules.templateFamily">Family</button>
|
||||
<button class="secondary" data-schedule-template="child" data-i18n="schedules.templateChild">Child room</button>
|
||||
<button class="secondary" data-schedule-template="bedroom" data-i18n="schedules.templateBedroom">Bedroom</button>
|
||||
<button class="secondary" data-schedule-template="workday" data-i18n="schedules.templateWorkday">Workday</button>
|
||||
<button class="secondary" data-schedule-template="always" data-i18n="schedules.templateAlways">Always comfort</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-grid" id="scheduleList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="automations">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="automations.rules">Rules</span><h1 data-i18n="nav.automations">Automations</h1></div><button class="secondary" data-open="automationDialog" data-i18n="actions.add">Add</button></div>
|
||||
<div class="panel automation-hint"><strong data-i18n="automations.smartHintTitle">Use thermostat presets for daily comfort</strong><p data-i18n="automations.smartHint">Schedules and zone presets handle normal heating/cooling automatically. Keep automations for special exceptions so they do not fight the thermostat.</p></div>
|
||||
<div class="list-grid" id="automationList"></div>
|
||||
</section>
|
||||
|
||||
@@ -107,7 +122,11 @@
|
||||
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://10.87.65.2.</p>
|
||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
|
||||
@@ -127,11 +146,15 @@
|
||||
</main>
|
||||
|
||||
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
|
||||
<button class="active" data-nav="dashboard"><span>⌂</span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
||||
<button data-nav="devices"><span>▣</span><b data-i18n="nav.devices">Devices</b></button>
|
||||
<button data-nav="zones"><span>◎</span><b data-i18n="nav.zones">Zones</b></button>
|
||||
<button data-nav="history"><span>⌁</span><b data-i18n="nav.history">History</b></button>
|
||||
<button data-nav="more"><span>•••</span><b data-i18n="nav.more">More</b></button>
|
||||
<button class="active" data-nav="dashboard"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M3 10.5 12 3l9 7.5"/><path d="M5.5 9.5V21h13V9.5"/><path d="M9 21v-7h6v7"/></svg></span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
||||
<button data-nav="devices"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h4"/></svg></span><b data-i18n="nav.devices">Devices</b></button>
|
||||
<button data-nav="zones"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg></span><b data-i18n="nav.zones">Zones</b></button>
|
||||
<button data-nav="history"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M4 19V5"/><path d="M4 19h16"/><path d="m7 15 4-4 3 2 5-6"/></svg></span><b data-i18n="nav.history">History</b></button>
|
||||
<button class="desktop-nav-only" data-nav="schedules"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></svg></span><b data-i18n="nav.schedules">Schedules</b></button>
|
||||
<button class="desktop-nav-only" data-nav="automations"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="m13 2-8 12h7l-1 8 8-12h-7z"/></svg></span><b data-i18n="nav.automations">Automations</b></button>
|
||||
<button class="desktop-nav-only" data-nav="settings"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.86 2.86-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.08V21h-4v-.08A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.86-2.86.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.08-.4H3v-4h.08A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06L7.06 3.8l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.08V3h4v.08A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.86 2.86-.06.06A1.7 1.7 0 0 0 19.4 9c.36.3.7.65 1 1 .2.3.4.65.4 1v2c0 .35-.2.7-.4 1-.3.35-.64.7-1 1z"/></svg></span><b data-i18n="nav.settings">Settings</b></button>
|
||||
<button class="desktop-nav-only" data-nav="logs"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg></span><b data-i18n="nav.logs">Events</b></button>
|
||||
<button data-nav="more"><span><svg viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg></span><b data-i18n="nav.more">More</b></button>
|
||||
</nav>
|
||||
|
||||
<dialog id="moreDialog" class="sheet">
|
||||
@@ -194,14 +217,20 @@
|
||||
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
|
||||
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
||||
<div class="two"><label><span data-i18n="common.mode">Mode</span><select name="mode"><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option></select></label><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||
<div class="two"><label><span data-i18n="zones.modePolicy">Mode policy</span><select name="mode_policy"><option value="house" data-i18n="zones.followHouse">Follow house mode</option><option value="cool" data-i18n="mode.cool">Cooling only</option><option value="heat" data-i18n="mode.heat">Heating only</option></select></label><label><span data-i18n="common.targetC">Manual target °C</span><input type="number" step="0.5" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||
<details class="profile-editor"><summary data-i18n="zones.profileTemperatures">Comfort / sleep / away temperatures</summary>
|
||||
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="number" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="number" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="number" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
||||
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="number" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="number" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="number" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
||||
</details>
|
||||
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="number" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||
<div id="externalSensorFields">
|
||||
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="number" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
|
||||
<p class="field-note" data-i18n="zones.sensorHelp">The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
|
||||
</div>
|
||||
<div class="two"><label><span data-i18n="zones.minOn">Min. ON (s)</span><input type="number" min="0" name="min_on_seconds" value="180"></label><label><span data-i18n="zones.minOff">Min. OFF (s)</span><input type="number" min="0" name="min_off_seconds" value="180"></label></div>
|
||||
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="number" step="0.5" min="0.5" max="8" name="standby_offset_c" value="2"></label></div>
|
||||
<label class="check"><input type="checkbox" name="smart_fan" checked> <span data-i18n="zones.smartFan">Smart fan assist</span></label>
|
||||
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds" value="180">
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
@@ -215,7 +244,8 @@
|
||||
<label><span data-i18n="common.zone">Zone</span><select name="zone_id" required></select></label>
|
||||
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
|
||||
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
|
||||
<label><span data-i18n="common.temperatureC">Temperature °C</span><input type="number" name="setpoint" step="0.1" min="8" max="30" value="23" required></label>
|
||||
<label><span data-i18n="schedules.profile">Profile</span><select name="preset"><option value="comfort" data-i18n="preset.comfort">Comfort</option><option value="sleep" data-i18n="preset.sleep">Sleep</option><option value="away" data-i18n="preset.away">Away</option><option value="custom" data-i18n="preset.custom">Custom temperature</option></select></label>
|
||||
<label id="scheduleSetpointField"><span data-i18n="common.temperatureC">Custom temperature °C</span><input type="number" name="setpoint" step="0.5" min="8" max="30" value="23"></label>
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "GREE",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f7f6",
|
||||
"theme_color": "#0d1715",
|
||||
"background_color": "#151515",
|
||||
"theme_color": "#151515",
|
||||
"icons": [{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]
|
||||
}
|
||||
|
||||
+528
-193
@@ -1,247 +1,582 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a1110;
|
||||
--surface: #111d1a;
|
||||
--surface-2: #172622;
|
||||
--surface-muted: #14201d;
|
||||
--input-bg: #0c1614;
|
||||
--nav-bg: #0d1715;
|
||||
--dialog-bg: #101c19;
|
||||
--line: rgba(255,255,255,.10);
|
||||
--text: #f3faf7;
|
||||
--muted: #91aaa0;
|
||||
--accent: #5ee2a0;
|
||||
--accent-strong: #28c779;
|
||||
--accent-text: #092218;
|
||||
--danger: #ff8b8b;
|
||||
--warning: #f6c866;
|
||||
--backdrop: rgba(1,8,6,.72);
|
||||
--grid: rgba(255,255,255,.08);
|
||||
--radius: 22px;
|
||||
--bg: #151515;
|
||||
--canvas: #181818;
|
||||
--surface: #1c1c1c;
|
||||
--surface-2: #222222;
|
||||
--surface-3: #282828;
|
||||
--surface-muted: #202020;
|
||||
--input-bg: #181818;
|
||||
--nav-bg: #171717;
|
||||
--dialog-bg: #1c1c1c;
|
||||
--line: #2c2c2c;
|
||||
--border: #2c2c2c;
|
||||
--line-strong: #3a3a3a;
|
||||
--text: #ededed;
|
||||
--text-soft: #d4d4d4;
|
||||
--muted: #a1a1aa;
|
||||
--muted-2: #737373;
|
||||
--accent: #3ecf8e;
|
||||
--accent-strong: #46d99a;
|
||||
--accent-soft: rgba(62, 207, 142, .10);
|
||||
--accent-border: rgba(62, 207, 142, .36);
|
||||
--accent-text: #06281a;
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, .10);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, .10);
|
||||
--backdrop: rgba(0, 0, 0, .68);
|
||||
--grid: rgba(255, 255, 255, .07);
|
||||
--scroll-thumb: #444444;
|
||||
--scroll-thumb-hover: #575757;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--control-h: 38px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f6;
|
||||
--bg: #f8faf9;
|
||||
--canvas: #f8faf9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #edf2ef;
|
||||
--surface-muted: #f0f4f2;
|
||||
--surface-2: #f5f6f6;
|
||||
--surface-3: #eef0ef;
|
||||
--surface-muted: #f6f7f7;
|
||||
--input-bg: #ffffff;
|
||||
--nav-bg: #ffffff;
|
||||
--dialog-bg: #ffffff;
|
||||
--line: rgba(19,42,33,.14);
|
||||
--text: #14221d;
|
||||
--muted: #62756d;
|
||||
--accent: #20b86b;
|
||||
--accent-strong: #149557;
|
||||
--line: #e5e7e6;
|
||||
--border: #e5e7e6;
|
||||
--line-strong: #d4d7d5;
|
||||
--text: #1c1c1c;
|
||||
--text-soft: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-2: #a1a1aa;
|
||||
--accent: #24b47e;
|
||||
--accent-strong: #1ea672;
|
||||
--accent-soft: rgba(36, 180, 126, .09);
|
||||
--accent-border: rgba(36, 180, 126, .32);
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c94646;
|
||||
--warning: #b77900;
|
||||
--backdrop: rgba(18,33,27,.28);
|
||||
--grid: rgba(19,42,33,.10);
|
||||
--danger: #dc5f5f;
|
||||
--danger-soft: rgba(220, 95, 95, .08);
|
||||
--warning: #b7791f;
|
||||
--warning-soft: rgba(183, 121, 31, .08);
|
||||
--backdrop: rgba(23, 23, 23, .32);
|
||||
--grid: rgba(24, 24, 27, .08);
|
||||
--scroll-thumb: #c8ccca;
|
||||
--scroll-thumb-hover: #aeb3b0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { background: var(--bg); }
|
||||
body { margin: 0; min-height: 100vh; color: var(--text); }
|
||||
button, input, select { font: inherit; }
|
||||
html { background: var(--bg); }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: wait; }
|
||||
.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 72px; padding: 12px max(18px, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 94%, transparent); backdrop-filter: blur(14px); }
|
||||
button:disabled { opacity: .48; cursor: wait; }
|
||||
a { color: var(--accent); }
|
||||
::selection { color: var(--text); background: var(--accent-soft); }
|
||||
|
||||
/* Accessible focus treatment in the same restrained style as the rest of the UI. */
|
||||
:where(button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 54px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.brand { display: flex; align-items: center; min-width: 0; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 15px; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||
.brand.large { margin-bottom: 1rem; }
|
||||
.brand.large strong { font-size: 21px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
|
||||
.toolbar-select { width: auto; min-width: 58px; min-height: 40px; padding: 7px 28px 7px 10px; border-radius: 11px; }
|
||||
.theme-select { min-width: 94px; }
|
||||
button { border: 0; border-radius: 13px; padding: 11px 15px; color: var(--text); background: var(--surface-2); transition: transform .15s ease, background .15s ease, opacity .15s ease; }
|
||||
button:active { transform: scale(.97); }
|
||||
.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; }
|
||||
.primary:hover { background: var(--accent-strong); }
|
||||
.secondary { border: 1px solid var(--line); background: var(--surface-muted); font-weight: 650; }
|
||||
.secondary:hover { background: var(--surface-2); }
|
||||
.brand strong { color: var(--text); font-size: 13px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.brand small { margin-top: 1px; color: var(--muted); font-size: 10px; }
|
||||
.brand.large { margin-bottom: 16px; }
|
||||
.brand.large strong { font-size: 20px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
|
||||
.toolbar-select {
|
||||
width: auto;
|
||||
min-width: 58px;
|
||||
min-height: 34px;
|
||||
padding: 6px 28px 6px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.theme-select { min-width: 92px; }
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 520;
|
||||
transition: border-color .12s ease, background-color .12s ease, color .12s ease, opacity .12s ease;
|
||||
}
|
||||
button:hover { border-color: var(--line-strong); background: var(--surface-2); color: var(--text); }
|
||||
button:active { background: var(--surface-3); }
|
||||
.primary {
|
||||
border-color: color-mix(in srgb, var(--accent) 74%, var(--line));
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.primary:hover { border-color: var(--accent-strong); color: var(--accent-text); background: var(--accent-strong); }
|
||||
.secondary { border-color: var(--line); color: var(--text-soft); background: var(--surface); font-weight: 540; }
|
||||
.secondary:hover { border-color: var(--line-strong); background: var(--surface-2); }
|
||||
.danger { color: var(--danger); }
|
||||
.compact { padding: 10px 14px; }
|
||||
.icon-button { width: 40px; height: 40px; padding: 0; font-size: 22px; background: transparent; }
|
||||
main { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 108px; }
|
||||
.view { display: none; animation: reveal .2s ease; }
|
||||
.view.active { display: block; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } }
|
||||
.danger:hover { border-color: color-mix(in srgb, var(--danger) 38%, var(--line)); background: var(--danger-soft); color: var(--danger); }
|
||||
.compact { min-height: 34px; padding: 7px 11px; }
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
.icon-button:hover { color: var(--text); background: var(--surface-2); }
|
||||
.icon-button svg { width: 16px; height: 16px; }
|
||||
|
||||
main {
|
||||
width: min(1280px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 102px;
|
||||
}
|
||||
.view { display: none; }
|
||||
.view.active { display: block; animation: reveal .12s ease-out; }
|
||||
@keyframes reveal { from { opacity: .65; transform: translateY(2px); } }
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 8px; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.045em; }
|
||||
h2 { margin-bottom: 0; font-size: clamp(23px, 4vw, 31px); letter-spacing: -.035em; }
|
||||
h3 { margin-bottom: 10px; }
|
||||
.eyebrow { color: var(--accent); font-size: 11px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.lead { max-width: 690px; color: var(--muted); line-height: 1.55; }
|
||||
.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 18px; min-height: 210px; padding: clamp(24px, 6vw, 44px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 30px; background: var(--surface); }
|
||||
.hero::after { content: ""; position: absolute; right: -80px; bottom: -120px; width: 220px; height: 220px; border: 34px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
|
||||
.hero h1 { margin: 5px 0 8px; }
|
||||
.hero p { margin: 0; color: var(--muted); }
|
||||
.hero-temp { z-index: 1; white-space: nowrap; font-size: clamp(48px, 12vw, 88px); font-weight: 250; letter-spacing: -.075em; }
|
||||
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 600; letter-spacing: 0; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 14px 0 34px; }
|
||||
.metric { padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
|
||||
.metric span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; }
|
||||
.metric strong { font-size: 24px; letter-spacing: -.03em; }
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 8px 0 18px; }
|
||||
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 14px; }
|
||||
h1 { margin-bottom: 7px; font-size: clamp(25px, 5vw, 34px); line-height: 1.08; font-weight: 620; letter-spacing: -.035em; }
|
||||
h2 { margin-bottom: 0; font-size: clamp(20px, 4vw, 25px); line-height: 1.15; font-weight: 610; letter-spacing: -.025em; }
|
||||
h3 { margin-bottom: 9px; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lead { max-width: 760px; color: var(--muted); font-size: 13px; line-height: 1.6; }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 154px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hero::after { display: none; }
|
||||
.hero h1 { margin: 4px 0 7px; }
|
||||
.hero p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-temp { z-index: 1; white-space: nowrap; color: var(--text); font-size: clamp(48px, 9vw, 68px); font-weight: 310; line-height: 1; letter-spacing: -.065em; }
|
||||
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 550; letter-spacing: 0; }
|
||||
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 10px 0 28px; }
|
||||
.metric { padding: 14px 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.metric span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 11px; }
|
||||
.metric strong { color: var(--text); font-size: 21px; font-weight: 590; letter-spacing: -.025em; }
|
||||
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin: 8px 0 14px; }
|
||||
.section-heading > div { min-width: 0; }
|
||||
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); gap: 10px; }
|
||||
.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.device-card { overflow: hidden; padding: 20px; }
|
||||
.device-card.off { opacity: .86; }
|
||||
.device-card { overflow: hidden; padding: 16px; }
|
||||
.device-card.off { opacity: .78; }
|
||||
.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title { min-width: 0; }
|
||||
.device-title h3 { overflow: hidden; margin: 0 0 4px; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; }
|
||||
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.device-title h3 { overflow: hidden; margin: 0 0 3px; color: var(--text); text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
|
||||
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
|
||||
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--danger); }
|
||||
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
|
||||
.status.online::before { background: var(--accent); }
|
||||
.power-button { display: grid; place-items: center; flex: 0 0 auto; width: 46px; height: 46px; padding: 0; border-radius: 50%; color: var(--muted); font-size: 21px; }
|
||||
.power-button.on { background: var(--accent); color: var(--accent-text); }
|
||||
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 24px 0 17px; }
|
||||
.temperature-control button { width: 42px; height: 42px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; font-size: 22px; }
|
||||
.target-temp { min-width: 106px; text-align: center; font-size: 48px; font-weight: 300; letter-spacing: -.06em; }
|
||||
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 15px; font-weight: 650; }
|
||||
.current-line { display: flex; justify-content: center; gap: 6px; margin: -10px 0 18px; color: var(--muted); font-size: 12px; }
|
||||
.current-line strong { color: var(--text); }
|
||||
.mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; }
|
||||
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; }
|
||||
.mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; }
|
||||
.mode-row button.active, .fan-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
|
||||
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
|
||||
.device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; }
|
||||
.device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-menu { display: flex; gap: 5px; }
|
||||
.card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; }
|
||||
.list-card { padding: 19px; }
|
||||
.power-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 17px;
|
||||
}
|
||||
.power-button.on { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 20px 0 14px; }
|
||||
.temperature-control button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 18px;
|
||||
}
|
||||
.target-temp { min-width: 100px; text-align: center; color: var(--text); font-size: 42px; font-weight: 320; line-height: 1; letter-spacing: -.055em; }
|
||||
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 13px; font-weight: 540; }
|
||||
.current-line { display: flex; justify-content: center; gap: 6px; margin: -6px 0 15px; color: var(--muted); font-size: 11px; }
|
||||
.current-line strong { color: var(--text-soft); font-weight: 560; }
|
||||
|
||||
.mode-row, .fan-row, .preset-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar, .preset-row::-webkit-scrollbar { display: none; }
|
||||
.mode-row button, .fan-row button, .preset-row button {
|
||||
flex: 1 0 auto;
|
||||
min-width: 56px;
|
||||
min-height: 32px;
|
||||
padding: 6px 9px;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 11px;
|
||||
font-weight: 540;
|
||||
}
|
||||
.mode-row button.active, .fan-row button.active, .preset-row button.active {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 620;
|
||||
}
|
||||
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 2px; }
|
||||
.device-toggles button { min-height: 32px; padding: 6px 5px; color: var(--muted); background: var(--surface-2); font-size: 10px; }
|
||||
.device-toggles button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||
.card-footer small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-menu { display: flex; gap: 3px; }
|
||||
.card-menu button { min-height: 28px; padding: 5px 7px; border-color: transparent; color: var(--muted); background: transparent; font-size: 10px; }
|
||||
.card-menu button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
|
||||
.list-card { padding: 15px; }
|
||||
.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.list-card h3 { margin: 0 0 5px; font-size: 18px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
|
||||
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; }
|
||||
.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; }
|
||||
.list-card h3 { margin: 0 0 4px; color: var(--text); font-size: 15px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 23px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface-2); font-size: 10px; font-weight: 550; }
|
||||
.badge.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 13px; }
|
||||
.card-stat { padding: 8px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: center; }
|
||||
.card-stat small, .card-stat strong { display: block; }
|
||||
.card-stat small { margin-bottom: 5px; color: var(--muted); font-size: 10px; }
|
||||
.card-stat strong { font-size: 15px; }
|
||||
.empty { grid-column: 1/-1; padding: 38px 24px; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); text-align: center; }
|
||||
.empty strong { display: block; margin-bottom: 7px; color: var(--text); }
|
||||
.panel { padding: 20px; }
|
||||
.card-stat small { margin-bottom: 4px; color: var(--muted); font-size: 9px; }
|
||||
.card-stat strong { color: var(--text-soft); font-size: 13px; font-weight: 590; }
|
||||
.empty { grid-column: 1/-1; padding: 34px 22px; border: 1px dashed var(--line-strong); border-radius: var(--radius); color: var(--muted); background: color-mix(in srgb, var(--surface) 50%, transparent); text-align: center; }
|
||||
.empty strong { display: block; margin-bottom: 6px; color: var(--text); }
|
||||
.empty.compact { padding: 16px; }
|
||||
|
||||
.panel { padding: 16px; }
|
||||
.chart-panel { overflow: hidden; }
|
||||
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 18px; }
|
||||
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
|
||||
.chart-toolbar select { flex: 1; min-width: 140px; }
|
||||
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; }
|
||||
#historyChart { width: 100%; min-width: 680px; height: 340px; }
|
||||
.legend { display: flex; gap: 18px; margin-top: 10px; color: var(--muted); font-size: 12px; }
|
||||
.legend > span { display: flex; align-items: center; gap: 7px; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
|
||||
.legend { display: flex; gap: 16px; margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.legend > span { display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.dot.target { background: var(--warning); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; }
|
||||
.form-grid hr, .dialog-form hr { width: 100%; border: 0; border-top: 1px solid var(--line); }
|
||||
label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
input, select { width: 100%; min-height: 45px; border: 1px solid var(--line); border-radius: 12px; outline: 0; padding: 10px 12px; color: var(--text); background: var(--input-bg); }
|
||||
input:focus, select:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; }
|
||||
.check input { width: 19px; min-height: 19px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; }
|
||||
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text); }
|
||||
.log-list { display: grid; gap: 2px; padding: 8px; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
||||
.form-grid h3 { margin: 2px 0 0; }
|
||||
.form-grid hr, .dialog-form hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid var(--line); }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 550; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
font-size: 13px;
|
||||
transition: border-color .12s ease, background-color .12s ease;
|
||||
}
|
||||
input:hover, select:hover, textarea:hover { border-color: var(--line-strong); }
|
||||
input:focus, select:focus, textarea:focus { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); }
|
||||
input::placeholder, textarea::placeholder { color: var(--muted-2); }
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 8px; min-height: 36px; }
|
||||
.check input { width: 16px; min-height: 16px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 4px; }
|
||||
.system-panel { margin-top: 10px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text-soft); font-weight: 580; }
|
||||
|
||||
.log-list { display: grid; gap: 0; padding: 0; overflow: hidden; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); font-size: 11px; }
|
||||
.log-row:last-child { border-bottom: 0; }
|
||||
.log-row:hover { background: var(--surface-2); }
|
||||
.log-row time, .log-row .kind { color: var(--muted); }
|
||||
.log-row.error .kind { color: var(--danger); }
|
||||
.log-row.warn .kind { color: var(--warning); }
|
||||
.bottom-nav { position: fixed; z-index: 30; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, 1fr); padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 96%, transparent); backdrop-filter: blur(14px); }
|
||||
.bottom-nav button { display: grid; place-items: center; gap: 3px; padding: 7px 2px; border-radius: 12px; color: var(--muted); background: transparent; font-size: 10px; }
|
||||
.bottom-nav button span { font-size: 20px; line-height: 1; }
|
||||
.bottom-nav button b { font-weight: 650; }
|
||||
.bottom-nav button.active { color: var(--accent); }
|
||||
dialog { width: min(540px, calc(100% - 24px)); max-height: min(88vh, 760px); overflow: auto; border: 1px solid var(--line); border-radius: 24px; padding: 0; color: var(--text); background: var(--dialog-bg); }
|
||||
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(4px); }
|
||||
.dialog-form { display: grid; gap: 14px; padding: 20px; }
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding: 6px max(8px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
min-height: 50px;
|
||||
padding: 5px 2px;
|
||||
border-color: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.bottom-nav button:hover { border-color: transparent; color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button span { display: grid; place-items: center; width: 20px; height: 20px; color: currentColor; font-size: 16px; line-height: 1; }
|
||||
.bottom-nav button span svg { width: 18px; height: 18px; stroke-width: 1.6; }
|
||||
.bottom-nav button b { font-weight: 540; }
|
||||
.bottom-nav button.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.desktop-nav-only { display: none !important; }
|
||||
|
||||
/* Dialogs deliberately have no drop shadow: separation comes from border + backdrop. */
|
||||
dialog {
|
||||
width: min(540px, calc(100% - 24px));
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--dialog-bg);
|
||||
}
|
||||
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
|
||||
.dialog-form { display: grid; gap: 12px; padding: 17px; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.dialog-head h2 { font-size: 23px; }
|
||||
.dialog-head button { width: 38px; height: 38px; padding: 0; background: transparent; color: var(--muted); font-size: 25px; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
#externalSensorFields { display: grid; gap: 12px; }
|
||||
.field-note { margin: -4px 0 0; color: var(--muted); font-size: .8rem; line-height: 1.45; }
|
||||
.sensor-detail { margin-top: 10px; color: var(--muted); font-size: .8rem; line-height: 1.45; }
|
||||
fieldset { border: 1px solid var(--line); border-radius: 14px; padding: 10px; }
|
||||
legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.dialog-head h2 { margin: 0; font-size: 18px; }
|
||||
.dialog-head button { display: grid; place-items: center; width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; border-color: transparent; color: var(--muted); background: transparent; font-size: 21px; }
|
||||
.dialog-head button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
#externalSensorFields { display: grid; gap: 10px; }
|
||||
.field-note { margin: -3px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.sensor-detail { margin-top: 9px; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 9px; }
|
||||
legend { padding: 0 5px; color: var(--muted); font-size: 10px; }
|
||||
.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.days label { display: grid; place-items: center; gap: 5px; padding: 6px 2px; border-radius: 8px; background: var(--surface-muted); font-size: 10px; }
|
||||
.days input { width: 16px; min-height: 16px; accent-color: var(--accent); }
|
||||
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 10px; }
|
||||
.sheet .dialog-head { padding: 18px 18px 5px; }
|
||||
.menu-list { display: grid; padding: 8px 10px 12px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 9px; background: transparent; text-align: left; }
|
||||
.days label { display: grid; place-items: center; gap: 4px; padding: 6px 2px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 9px; }
|
||||
.days input { width: 14px; min-height: 14px; accent-color: var(--accent); }
|
||||
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 8px; }
|
||||
.sheet .dialog-head { padding: 15px 15px 4px; }
|
||||
.menu-list { display: grid; padding: 7px 9px 10px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; min-height: 42px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 8px; background: transparent; text-align: left; }
|
||||
.menu-list button:hover { background: var(--surface-2); }
|
||||
.menu-list button:last-child { border-bottom: 0; }
|
||||
.auth-dialog { width: min(420px, calc(100% - 30px)); }
|
||||
#toast { position: fixed; z-index: 100; right: 18px; bottom: 92px; max-width: min(380px, calc(100% - 36px)); transform: translateY(15px); opacity: 0; pointer-events: none; padding: 12px 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); transition: .2s ease; font-size: 13px; }
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); }
|
||||
@media (min-width: 900px) {
|
||||
.bottom-nav { top: 92px; right: auto; bottom: auto; left: max(16px, calc((100vw - 1380px)/2)); grid-template-columns: 1fr; width: 90px; border: 1px solid var(--line); border-radius: 22px; padding: 9px; background: var(--surface); }
|
||||
.bottom-nav button { padding: 10px 4px; }
|
||||
main { padding-left: 122px; padding-bottom: 50px; }
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
right: 16px;
|
||||
bottom: 82px;
|
||||
max-width: min(380px, calc(100% - 32px));
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface-2);
|
||||
transition: .14s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); background: color-mix(in srgb, var(--danger-soft) 42%, var(--surface-2)); }
|
||||
|
||||
.token-manager { display: grid; gap: 10px; }
|
||||
.token-list { display: grid; gap: 6px; }
|
||||
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.token-row > div { min-width: 0; display: grid; gap: 2px; }
|
||||
.token-row strong { color: var(--text); font-size: 12px; font-weight: 590; }
|
||||
.token-row small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
|
||||
/* Zone quick controls */
|
||||
.zone-quick-control { display: grid; gap: 4px; margin-top: 9px; padding: 8px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.temperature-control.compact { padding: 3px 0 6px; gap: 12px; }
|
||||
.temperature-control.compact button { width: 34px; height: 34px; min-height: 34px; }
|
||||
.target-temp.compact { min-width: 86px; font-size: 29px; }
|
||||
.zone-quick-control .mode-row { padding-bottom: 1px; }
|
||||
|
||||
.discovery-name-list { display: grid; gap: 10px; }
|
||||
.discovery-name-row { display: grid; gap: 7px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.discovery-name-row:last-child { border-bottom: 0; }
|
||||
.discovery-name-row span { display: grid; gap: 2px; }
|
||||
.discovery-name-row small { color: var(--muted); overflow-wrap: anywhere; }
|
||||
|
||||
/* Quiet themed scrollbars. No bright native tracks in dark mode. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--scroll-thumb); background-clip: padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); background-clip: padding-box; }
|
||||
|
||||
.house-climate { display: grid; gap: 13px; margin: -12px 0 28px; }
|
||||
.house-climate-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.house-climate-head h3 { margin: 2px 0 3px; font-size: 15px; }
|
||||
.house-climate-head p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.outside-pill { flex: 0 0 auto; min-width: 90px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: right; }
|
||||
.outside-pill small, .outside-pill strong { display: block; }
|
||||
.outside-pill small { color: var(--muted); font-size: 9px; }
|
||||
.outside-pill strong { margin-top: 2px; color: var(--text); font-size: 16px; font-weight: 590; }
|
||||
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.house-mode-row button { min-height: 36px; color: var(--muted); background: var(--surface-2); }
|
||||
.house-mode-row button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); font-weight: 620; }
|
||||
.mode-indicator { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 999px; vertical-align: 1px; background: var(--muted-2); }
|
||||
.house-mode-row button[data-value="cool"] .mode-indicator { background: #60a5fa; }
|
||||
.house-mode-row button[data-value="heat"] .mode-indicator { background: var(--warning); }
|
||||
.house-mode-row button.active .mode-indicator { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 9%, transparent); }
|
||||
|
||||
.zone-thermostat { overflow: hidden; }
|
||||
.zone-thermostat.demanding { border-color: var(--accent-border); }
|
||||
.thermostat-main { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; margin: 13px 0 8px; }
|
||||
.thermostat-main > div:first-child { text-align: left; }
|
||||
.thermostat-main > div:last-child { text-align: right; }
|
||||
.thermostat-main small, .thermostat-main strong { display: block; }
|
||||
.thermostat-main small { margin-bottom: 3px; color: var(--muted); font-size: 9px; }
|
||||
.thermostat-main strong { color: var(--text-soft); font-size: 14px; font-weight: 590; }
|
||||
.zone-mode-row { padding-bottom: 4px; }
|
||||
.zone-state-line { display: flex; justify-content: space-between; gap: 10px; padding: 7px 1px 0; color: var(--muted); font-size: 10px; }
|
||||
.zone-state-line span:last-child { text-align: right; }
|
||||
.dashboard-zones { margin-bottom: 28px; }
|
||||
|
||||
.schedule-template-panel { display: grid; gap: 11px; margin-bottom: 14px; }
|
||||
.schedule-template-panel h3 { margin-bottom: 3px; }
|
||||
.template-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 6px; }
|
||||
.profile-editor { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 10px; background: var(--surface-2); }
|
||||
.profile-editor summary { cursor: pointer; color: var(--text-soft); font-weight: 590; font-size: 12px; }
|
||||
.profile-editor[open] summary { margin-bottom: 10px; }
|
||||
.profile-grid { display: grid; grid-template-columns: 90px repeat(3, 1fr); align-items: end; gap: 7px; margin-top: 7px; }
|
||||
.profile-grid > strong { align-self: center; font-size: 11px; }
|
||||
.profile-grid label span { font-size: 9px; }
|
||||
.warning-note { color: var(--warning); }
|
||||
.automation-hint { margin-bottom: 14px; }
|
||||
.automation-hint strong { display: block; margin-bottom: 4px; color: var(--text-soft); font-size: 12px; }
|
||||
.automation-hint p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.topbar { padding-left: 14px; }
|
||||
.brand { width: 176px; flex: 0 0 176px; }
|
||||
.bottom-nav {
|
||||
top: 54px;
|
||||
right: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
width: 190px;
|
||||
padding: 10px 8px;
|
||||
border-top: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
grid-template-columns: 22px 1fr;
|
||||
justify-items: start;
|
||||
place-items: center start;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bottom-nav button span { width: 18px; height: 18px; }
|
||||
.bottom-nav button b { font-size: 12px; }
|
||||
.bottom-nav button.active { color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button.active span { color: var(--accent); }
|
||||
.bottom-nav .desktop-nav-only { display: grid !important; }
|
||||
.bottom-nav button[data-nav="more"] { display: none; }
|
||||
main {
|
||||
width: min(1360px, calc(100% - 190px));
|
||||
margin: 0 0 0 190px;
|
||||
padding: 30px 28px 56px;
|
||||
}
|
||||
#toast { bottom: 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar { flex-wrap: wrap; }
|
||||
.topbar { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
||||
.top-actions { width: 100%; }
|
||||
.top-actions .primary { margin-left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
main { padding: 18px 13px 105px; }
|
||||
.hero { min-height: 190px; padding: 23px; }
|
||||
.hero-temp { font-size: 52px; }
|
||||
.metrics { gap: 7px; }
|
||||
.metric { padding: 14px 10px; }
|
||||
.metric strong { font-size: 20px; }
|
||||
main { padding: 15px 11px 92px; }
|
||||
.hero { min-height: 142px; padding: 18px; }
|
||||
.hero-temp { font-size: 48px; }
|
||||
.metrics { gap: 6px; }
|
||||
.metric { padding: 11px 9px; }
|
||||
.metric strong { font-size: 18px; }
|
||||
.section-heading { align-items: center; }
|
||||
.section-heading button { padding: 9px 11px; font-size: 12px; }
|
||||
.section-heading button { min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; }
|
||||
.log-row { grid-template-columns: 62px 1fr; }
|
||||
.log-row .message { grid-column: 1/-1; }
|
||||
.theme-select { min-width: 86px; }
|
||||
}
|
||||
@media (max-width: 410px) {
|
||||
.topbar { padding-inline: 12px; }
|
||||
.brand strong { font-size: 13px; }
|
||||
.hero { align-items: start; flex-direction: column; }
|
||||
.hero-temp { align-self: flex-end; margin-top: -15px; }
|
||||
.two { grid-template-columns: 1fr; }
|
||||
.toolbar-select { min-width: 54px; }
|
||||
.theme-select { min-width: 82px; }
|
||||
}
|
||||
|
||||
.token-manager { display: grid; gap: 12px; }
|
||||
.token-list { display: grid; gap: 8px; }
|
||||
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-muted); }
|
||||
.token-row > div { min-width: 0; display: grid; gap: 3px; }
|
||||
.token-row strong { color: var(--text); font-size: 13px; }
|
||||
.token-row small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
.empty.compact { padding: 18px; }
|
||||
@media (max-width: 620px) {
|
||||
.theme-select { min-width: 84px; }
|
||||
.token-row { align-items: stretch; flex-direction: column; }
|
||||
.token-row button { width: 100%; }
|
||||
.house-climate { margin-top: -18px; }
|
||||
.house-climate-head { align-items: flex-start; }
|
||||
.house-climate-head p { max-width: 230px; }
|
||||
.thermostat-main { grid-template-columns: 1fr auto 1fr; gap: 4px; }
|
||||
.thermostat-main .temperature-control.compact { gap: 7px; }
|
||||
.thermostat-main .target-temp.compact { min-width: 70px; font-size: 26px; }
|
||||
.profile-grid { grid-template-columns: 1fr 1fr; }
|
||||
.profile-grid > strong { grid-column: 1/-1; margin-top: 3px; }
|
||||
.zone-state-line { flex-direction: column; gap: 2px; }
|
||||
.zone-state-line span:last-child { text-align: left; }
|
||||
}
|
||||
|
||||
/* Zone quick controls */
|
||||
.zone-quick-control { display: grid; gap: 4px; margin-top: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-muted); }
|
||||
.temperature-control.compact { padding: 4px 0 7px; gap: 16px; }
|
||||
.temperature-control.compact button { width: 38px; height: 38px; }
|
||||
.target-temp.compact { min-width: 92px; font-size: 32px; }
|
||||
.zone-quick-control .mode-row { padding-bottom: 2px; }
|
||||
|
||||
.discovery-name-list { display:grid; gap:12px; }
|
||||
.discovery-name-row { display:grid; gap:8px; padding:12px 0; border-bottom:1px solid var(--border); }
|
||||
.discovery-name-row:last-child { border-bottom:0; }
|
||||
.discovery-name-row span { display:grid; gap:2px; }
|
||||
.discovery-name-row small { color:var(--muted); overflow-wrap:anywhere; }
|
||||
@media (max-width: 410px) {
|
||||
.brand strong { font-size: 12px; }
|
||||
.hero { align-items: flex-start; flex-direction: column; }
|
||||
.hero-temp { align-self: flex-end; margin-top: -12px; }
|
||||
.two { grid-template-columns: 1fr; }
|
||||
.toolbar-select { min-width: 52px; }
|
||||
.theme-select { min-width: 78px; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v5';
|
||||
const CACHE = 'gree-controller-v041';
|
||||
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
|
||||
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
||||
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* GREE Controller semantic component layer. This file is the Tailwind build source. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #151515;
|
||||
--canvas: #181818;
|
||||
--surface: #1c1c1c;
|
||||
--surface-2: #222222;
|
||||
--surface-3: #282828;
|
||||
--surface-muted: #202020;
|
||||
--input-bg: #181818;
|
||||
--nav-bg: #171717;
|
||||
--dialog-bg: #1c1c1c;
|
||||
--line: #2c2c2c;
|
||||
--border: #2c2c2c;
|
||||
--line-strong: #3a3a3a;
|
||||
--text: #ededed;
|
||||
--text-soft: #d4d4d4;
|
||||
--muted: #a1a1aa;
|
||||
--muted-2: #737373;
|
||||
--accent: #3ecf8e;
|
||||
--accent-strong: #46d99a;
|
||||
--accent-soft: rgba(62, 207, 142, .10);
|
||||
--accent-border: rgba(62, 207, 142, .36);
|
||||
--accent-text: #06281a;
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, .10);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, .10);
|
||||
--backdrop: rgba(0, 0, 0, .68);
|
||||
--grid: rgba(255, 255, 255, .07);
|
||||
--scroll-thumb: #444444;
|
||||
--scroll-thumb-hover: #575757;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--control-h: 38px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f8faf9;
|
||||
--canvas: #f8faf9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f5f6f6;
|
||||
--surface-3: #eef0ef;
|
||||
--surface-muted: #f6f7f7;
|
||||
--input-bg: #ffffff;
|
||||
--nav-bg: #ffffff;
|
||||
--dialog-bg: #ffffff;
|
||||
--line: #e5e7e6;
|
||||
--border: #e5e7e6;
|
||||
--line-strong: #d4d7d5;
|
||||
--text: #1c1c1c;
|
||||
--text-soft: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-2: #a1a1aa;
|
||||
--accent: #24b47e;
|
||||
--accent-strong: #1ea672;
|
||||
--accent-soft: rgba(36, 180, 126, .09);
|
||||
--accent-border: rgba(36, 180, 126, .32);
|
||||
--accent-text: #ffffff;
|
||||
--danger: #dc5f5f;
|
||||
--danger-soft: rgba(220, 95, 95, .08);
|
||||
--warning: #b7791f;
|
||||
--warning-soft: rgba(183, 121, 31, .08);
|
||||
--backdrop: rgba(23, 23, 23, .32);
|
||||
--grid: rgba(24, 24, 27, .08);
|
||||
--scroll-thumb: #c8ccca;
|
||||
--scroll-thumb-hover: #aeb3b0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .48; cursor: wait; }
|
||||
a { color: var(--accent); }
|
||||
::selection { color: var(--text); background: var(--accent-soft); }
|
||||
|
||||
/* Accessible focus treatment in the same restrained style as the rest of the UI. */
|
||||
:where(button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 54px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.brand { display: flex; align-items: center; min-width: 0; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { color: var(--text); font-size: 13px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.brand small { margin-top: 1px; color: var(--muted); font-size: 10px; }
|
||||
.brand.large { margin-bottom: 16px; }
|
||||
.brand.large strong { font-size: 20px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
|
||||
.toolbar-select {
|
||||
width: auto;
|
||||
min-width: 58px;
|
||||
min-height: 34px;
|
||||
padding: 6px 28px 6px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.theme-select { min-width: 92px; }
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 520;
|
||||
transition: border-color .12s ease, background-color .12s ease, color .12s ease, opacity .12s ease;
|
||||
}
|
||||
button:hover { border-color: var(--line-strong); background: var(--surface-2); color: var(--text); }
|
||||
button:active { background: var(--surface-3); }
|
||||
.primary {
|
||||
border-color: color-mix(in srgb, var(--accent) 74%, var(--line));
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.primary:hover { border-color: var(--accent-strong); color: var(--accent-text); background: var(--accent-strong); }
|
||||
.secondary { border-color: var(--line); color: var(--text-soft); background: var(--surface); font-weight: 540; }
|
||||
.secondary:hover { border-color: var(--line-strong); background: var(--surface-2); }
|
||||
.danger { color: var(--danger); }
|
||||
.danger:hover { border-color: color-mix(in srgb, var(--danger) 38%, var(--line)); background: var(--danger-soft); color: var(--danger); }
|
||||
.compact { min-height: 34px; padding: 7px 11px; }
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
.icon-button:hover { color: var(--text); background: var(--surface-2); }
|
||||
.icon-button svg { width: 16px; height: 16px; }
|
||||
|
||||
main {
|
||||
width: min(1280px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 102px;
|
||||
}
|
||||
.view { display: none; }
|
||||
.view.active { display: block; animation: reveal .12s ease-out; }
|
||||
@keyframes reveal { from { opacity: .65; transform: translateY(2px); } }
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 7px; font-size: clamp(25px, 5vw, 34px); line-height: 1.08; font-weight: 620; letter-spacing: -.035em; }
|
||||
h2 { margin-bottom: 0; font-size: clamp(20px, 4vw, 25px); line-height: 1.15; font-weight: 610; letter-spacing: -.025em; }
|
||||
h3 { margin-bottom: 9px; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lead { max-width: 760px; color: var(--muted); font-size: 13px; line-height: 1.6; }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 154px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hero::after { display: none; }
|
||||
.hero h1 { margin: 4px 0 7px; }
|
||||
.hero p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-temp { z-index: 1; white-space: nowrap; color: var(--text); font-size: clamp(48px, 9vw, 68px); font-weight: 310; line-height: 1; letter-spacing: -.065em; }
|
||||
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 550; letter-spacing: 0; }
|
||||
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 10px 0 28px; }
|
||||
.metric { padding: 14px 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.metric span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 11px; }
|
||||
.metric strong { color: var(--text); font-size: 21px; font-weight: 590; letter-spacing: -.025em; }
|
||||
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin: 8px 0 14px; }
|
||||
.section-heading > div { min-width: 0; }
|
||||
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); gap: 10px; }
|
||||
.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.device-card { overflow: hidden; padding: 16px; }
|
||||
.device-card.off { opacity: .78; }
|
||||
.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title { min-width: 0; }
|
||||
.device-title h3 { overflow: hidden; margin: 0 0 3px; color: var(--text); text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
|
||||
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
|
||||
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
|
||||
.status.online::before { background: var(--accent); }
|
||||
.power-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 17px;
|
||||
}
|
||||
.power-button.on { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 20px 0 14px; }
|
||||
.temperature-control button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 18px;
|
||||
}
|
||||
.target-temp { min-width: 100px; text-align: center; color: var(--text); font-size: 42px; font-weight: 320; line-height: 1; letter-spacing: -.055em; }
|
||||
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 13px; font-weight: 540; }
|
||||
.current-line { display: flex; justify-content: center; gap: 6px; margin: -6px 0 15px; color: var(--muted); font-size: 11px; }
|
||||
.current-line strong { color: var(--text-soft); font-weight: 560; }
|
||||
|
||||
.mode-row, .fan-row, .preset-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar, .preset-row::-webkit-scrollbar { display: none; }
|
||||
.mode-row button, .fan-row button, .preset-row button {
|
||||
flex: 1 0 auto;
|
||||
min-width: 56px;
|
||||
min-height: 32px;
|
||||
padding: 6px 9px;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 11px;
|
||||
font-weight: 540;
|
||||
}
|
||||
.mode-row button.active, .fan-row button.active, .preset-row button.active {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 620;
|
||||
}
|
||||
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 2px; }
|
||||
.device-toggles button { min-height: 32px; padding: 6px 5px; color: var(--muted); background: var(--surface-2); font-size: 10px; }
|
||||
.device-toggles button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||
.card-footer small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-menu { display: flex; gap: 3px; }
|
||||
.card-menu button { min-height: 28px; padding: 5px 7px; border-color: transparent; color: var(--muted); background: transparent; font-size: 10px; }
|
||||
.card-menu button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
|
||||
.list-card { padding: 15px; }
|
||||
.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.list-card h3 { margin: 0 0 4px; color: var(--text); font-size: 15px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 23px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface-2); font-size: 10px; font-weight: 550; }
|
||||
.badge.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 13px; }
|
||||
.card-stat { padding: 8px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: center; }
|
||||
.card-stat small, .card-stat strong { display: block; }
|
||||
.card-stat small { margin-bottom: 4px; color: var(--muted); font-size: 9px; }
|
||||
.card-stat strong { color: var(--text-soft); font-size: 13px; font-weight: 590; }
|
||||
.empty { grid-column: 1/-1; padding: 34px 22px; border: 1px dashed var(--line-strong); border-radius: var(--radius); color: var(--muted); background: color-mix(in srgb, var(--surface) 50%, transparent); text-align: center; }
|
||||
.empty strong { display: block; margin-bottom: 6px; color: var(--text); }
|
||||
.empty.compact { padding: 16px; }
|
||||
|
||||
.panel { padding: 16px; }
|
||||
.chart-panel { overflow: hidden; }
|
||||
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
|
||||
.chart-toolbar select { flex: 1; min-width: 140px; }
|
||||
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; }
|
||||
#historyChart { width: 100%; min-width: 680px; height: 340px; }
|
||||
.legend { display: flex; gap: 16px; margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.legend > span { display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.dot.target { background: var(--warning); }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; }
|
||||
.form-grid h3 { margin: 2px 0 0; }
|
||||
.form-grid hr, .dialog-form hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid var(--line); }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 550; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
font-size: 13px;
|
||||
transition: border-color .12s ease, background-color .12s ease;
|
||||
}
|
||||
input:hover, select:hover, textarea:hover { border-color: var(--line-strong); }
|
||||
input:focus, select:focus, textarea:focus { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); }
|
||||
input::placeholder, textarea::placeholder { color: var(--muted-2); }
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 8px; min-height: 36px; }
|
||||
.check input { width: 16px; min-height: 16px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 4px; }
|
||||
.system-panel { margin-top: 10px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text-soft); font-weight: 580; }
|
||||
|
||||
.log-list { display: grid; gap: 0; padding: 0; overflow: hidden; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); font-size: 11px; }
|
||||
.log-row:last-child { border-bottom: 0; }
|
||||
.log-row:hover { background: var(--surface-2); }
|
||||
.log-row time, .log-row .kind { color: var(--muted); }
|
||||
.log-row.error .kind { color: var(--danger); }
|
||||
.log-row.warn .kind { color: var(--warning); }
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding: 6px max(8px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
min-height: 50px;
|
||||
padding: 5px 2px;
|
||||
border-color: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.bottom-nav button:hover { border-color: transparent; color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button span { display: grid; place-items: center; width: 20px; height: 20px; color: currentColor; font-size: 16px; line-height: 1; }
|
||||
.bottom-nav button span svg { width: 18px; height: 18px; stroke-width: 1.6; }
|
||||
.bottom-nav button b { font-weight: 540; }
|
||||
.bottom-nav button.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.desktop-nav-only { display: none !important; }
|
||||
|
||||
/* Dialogs deliberately have no drop shadow: separation comes from border + backdrop. */
|
||||
dialog {
|
||||
width: min(540px, calc(100% - 24px));
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--dialog-bg);
|
||||
}
|
||||
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
|
||||
.dialog-form { display: grid; gap: 12px; padding: 17px; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.dialog-head h2 { margin: 0; font-size: 18px; }
|
||||
.dialog-head button { display: grid; place-items: center; width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; border-color: transparent; color: var(--muted); background: transparent; font-size: 21px; }
|
||||
.dialog-head button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
#externalSensorFields { display: grid; gap: 10px; }
|
||||
.field-note { margin: -3px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.sensor-detail { margin-top: 9px; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 9px; }
|
||||
legend { padding: 0 5px; color: var(--muted); font-size: 10px; }
|
||||
.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.days label { display: grid; place-items: center; gap: 4px; padding: 6px 2px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 9px; }
|
||||
.days input { width: 14px; min-height: 14px; accent-color: var(--accent); }
|
||||
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 8px; }
|
||||
.sheet .dialog-head { padding: 15px 15px 4px; }
|
||||
.menu-list { display: grid; padding: 7px 9px 10px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; min-height: 42px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 8px; background: transparent; text-align: left; }
|
||||
.menu-list button:hover { background: var(--surface-2); }
|
||||
.menu-list button:last-child { border-bottom: 0; }
|
||||
.auth-dialog { width: min(420px, calc(100% - 30px)); }
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
right: 16px;
|
||||
bottom: 82px;
|
||||
max-width: min(380px, calc(100% - 32px));
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface-2);
|
||||
transition: .14s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); background: color-mix(in srgb, var(--danger-soft) 42%, var(--surface-2)); }
|
||||
|
||||
.token-manager { display: grid; gap: 10px; }
|
||||
.token-list { display: grid; gap: 6px; }
|
||||
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.token-row > div { min-width: 0; display: grid; gap: 2px; }
|
||||
.token-row strong { color: var(--text); font-size: 12px; font-weight: 590; }
|
||||
.token-row small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
|
||||
/* Zone quick controls */
|
||||
.zone-quick-control { display: grid; gap: 4px; margin-top: 9px; padding: 8px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.temperature-control.compact { padding: 3px 0 6px; gap: 12px; }
|
||||
.temperature-control.compact button { width: 34px; height: 34px; min-height: 34px; }
|
||||
.target-temp.compact { min-width: 86px; font-size: 29px; }
|
||||
.zone-quick-control .mode-row { padding-bottom: 1px; }
|
||||
|
||||
.discovery-name-list { display: grid; gap: 10px; }
|
||||
.discovery-name-row { display: grid; gap: 7px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.discovery-name-row:last-child { border-bottom: 0; }
|
||||
.discovery-name-row span { display: grid; gap: 2px; }
|
||||
.discovery-name-row small { color: var(--muted); overflow-wrap: anywhere; }
|
||||
|
||||
/* Quiet themed scrollbars. No bright native tracks in dark mode. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--scroll-thumb); background-clip: padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); background-clip: padding-box; }
|
||||
|
||||
.house-climate { display: grid; gap: 13px; margin: -12px 0 28px; }
|
||||
.house-climate-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.house-climate-head h3 { margin: 2px 0 3px; font-size: 15px; }
|
||||
.house-climate-head p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.outside-pill { flex: 0 0 auto; min-width: 90px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: right; }
|
||||
.outside-pill small, .outside-pill strong { display: block; }
|
||||
.outside-pill small { color: var(--muted); font-size: 9px; }
|
||||
.outside-pill strong { margin-top: 2px; color: var(--text); font-size: 16px; font-weight: 590; }
|
||||
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.house-mode-row button { min-height: 36px; color: var(--muted); background: var(--surface-2); }
|
||||
.house-mode-row button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); font-weight: 620; }
|
||||
.mode-indicator { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 999px; vertical-align: 1px; background: var(--muted-2); }
|
||||
.house-mode-row button[data-value="cool"] .mode-indicator { background: #60a5fa; }
|
||||
.house-mode-row button[data-value="heat"] .mode-indicator { background: var(--warning); }
|
||||
.house-mode-row button.active .mode-indicator { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 9%, transparent); }
|
||||
|
||||
.zone-thermostat { overflow: hidden; }
|
||||
.zone-thermostat.demanding { border-color: var(--accent-border); }
|
||||
.thermostat-main { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; margin: 13px 0 8px; }
|
||||
.thermostat-main > div:first-child { text-align: left; }
|
||||
.thermostat-main > div:last-child { text-align: right; }
|
||||
.thermostat-main small, .thermostat-main strong { display: block; }
|
||||
.thermostat-main small { margin-bottom: 3px; color: var(--muted); font-size: 9px; }
|
||||
.thermostat-main strong { color: var(--text-soft); font-size: 14px; font-weight: 590; }
|
||||
.zone-mode-row { padding-bottom: 4px; }
|
||||
.zone-state-line { display: flex; justify-content: space-between; gap: 10px; padding: 7px 1px 0; color: var(--muted); font-size: 10px; }
|
||||
.zone-state-line span:last-child { text-align: right; }
|
||||
.dashboard-zones { margin-bottom: 28px; }
|
||||
|
||||
.schedule-template-panel { display: grid; gap: 11px; margin-bottom: 14px; }
|
||||
.schedule-template-panel h3 { margin-bottom: 3px; }
|
||||
.template-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 6px; }
|
||||
.profile-editor { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 10px; background: var(--surface-2); }
|
||||
.profile-editor summary { cursor: pointer; color: var(--text-soft); font-weight: 590; font-size: 12px; }
|
||||
.profile-editor[open] summary { margin-bottom: 10px; }
|
||||
.profile-grid { display: grid; grid-template-columns: 90px repeat(3, 1fr); align-items: end; gap: 7px; margin-top: 7px; }
|
||||
.profile-grid > strong { align-self: center; font-size: 11px; }
|
||||
.profile-grid label span { font-size: 9px; }
|
||||
.warning-note { color: var(--warning); }
|
||||
.automation-hint { margin-bottom: 14px; }
|
||||
.automation-hint strong { display: block; margin-bottom: 4px; color: var(--text-soft); font-size: 12px; }
|
||||
.automation-hint p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.topbar { padding-left: 14px; }
|
||||
.brand { width: 176px; flex: 0 0 176px; }
|
||||
.bottom-nav {
|
||||
top: 54px;
|
||||
right: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
width: 190px;
|
||||
padding: 10px 8px;
|
||||
border-top: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
grid-template-columns: 22px 1fr;
|
||||
justify-items: start;
|
||||
place-items: center start;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bottom-nav button span { width: 18px; height: 18px; }
|
||||
.bottom-nav button b { font-size: 12px; }
|
||||
.bottom-nav button.active { color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button.active span { color: var(--accent); }
|
||||
.bottom-nav .desktop-nav-only { display: grid !important; }
|
||||
.bottom-nav button[data-nav="more"] { display: none; }
|
||||
main {
|
||||
width: min(1360px, calc(100% - 190px));
|
||||
margin: 0 0 0 190px;
|
||||
padding: 30px 28px 56px;
|
||||
}
|
||||
#toast { bottom: 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
||||
.top-actions { width: 100%; }
|
||||
.top-actions .primary { margin-left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
main { padding: 15px 11px 92px; }
|
||||
.hero { min-height: 142px; padding: 18px; }
|
||||
.hero-temp { font-size: 48px; }
|
||||
.metrics { gap: 6px; }
|
||||
.metric { padding: 11px 9px; }
|
||||
.metric strong { font-size: 18px; }
|
||||
.section-heading { align-items: center; }
|
||||
.section-heading button { min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; }
|
||||
.log-row { grid-template-columns: 62px 1fr; }
|
||||
.log-row .message { grid-column: 1/-1; }
|
||||
.theme-select { min-width: 84px; }
|
||||
.token-row { align-items: stretch; flex-direction: column; }
|
||||
.token-row button { width: 100%; }
|
||||
.house-climate { margin-top: -18px; }
|
||||
.house-climate-head { align-items: flex-start; }
|
||||
.house-climate-head p { max-width: 230px; }
|
||||
.thermostat-main { grid-template-columns: 1fr auto 1fr; gap: 4px; }
|
||||
.thermostat-main .temperature-control.compact { gap: 7px; }
|
||||
.thermostat-main .target-temp.compact { min-width: 70px; font-size: 26px; }
|
||||
.profile-grid { grid-template-columns: 1fr 1fr; }
|
||||
.profile-grid > strong { grid-column: 1/-1; margin-top: 3px; }
|
||||
.zone-state-line { flex-direction: column; gap: 2px; }
|
||||
.zone-state-line span:last-child { text-align: left; }
|
||||
}
|
||||
|
||||
@media (max-width: 410px) {
|
||||
.brand strong { font-size: 12px; }
|
||||
.hero { align-items: flex-start; flex-direction: column; }
|
||||
.hero-temp { align-self: flex-end; margin-top: -12px; }
|
||||
.two { grid-template-columns: 1fr; }
|
||||
.toolbar-select { min-width: 52px; }
|
||||
.theme-select { min-width: 78px; }
|
||||
}
|
||||
Reference in New Issue
Block a user