v0.4.2
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
__pycache__
|
||||
__pycache__/
|
||||
|
||||
# Rust build output
|
||||
/target/
|
||||
|
||||
+29
-44
@@ -1,64 +1,49 @@
|
||||
# GREE Controller v0.4.1 - build and validation report
|
||||
# GREE Controller v0.4.2 - build and validation report
|
||||
|
||||
## Scope
|
||||
|
||||
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.
|
||||
Version 0.4.2 expands climate history and removes the Node/Tailwind build pipeline.
|
||||
|
||||
## UI redesign
|
||||
### History changes
|
||||
|
||||
The embedded interface was rebuilt around a Supabase-inspired dashboard language while keeping original GREE Controller branding and information architecture:
|
||||
- Added a dedicated `zone_readings` SQLite table.
|
||||
- History samples include GREE indoor temperature, optional Home Assistant room temperature, calculated control temperature, effective profile target, device setpoint, Home Assistant outdoor temperature, power, HVAC mode, fan speed, demand, control source and active preset.
|
||||
- Zone history is sampled no faster than every 15 seconds (or the configured device poll interval when longer) to avoid unnecessary SQLite growth.
|
||||
- Added `GET /api/history` for one zone or all zones.
|
||||
- History UI now supports a detailed per-zone view and common multi-zone comparison charts.
|
||||
- Existing `readings` data and tables remain intact; the new table is created automatically on startup.
|
||||
|
||||
- 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.
|
||||
### Web asset changes
|
||||
|
||||
## 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.
|
||||
- `web/styles.css` is now the only CSS source used by the application.
|
||||
- Removed `package.json`, `tailwind.config.js`, `web/tailwind.input.css` and `scripts/build-web.sh`.
|
||||
- Removed all Node/npm/Tailwind build hooks from `dev.sh`, `install.sh` and `update.sh`.
|
||||
- The CSS remains fully local and is embedded in the Rust binary with `include_str!`.
|
||||
|
||||
## Validation performed
|
||||
|
||||
Passed:
|
||||
Passed in the packaging environment:
|
||||
|
||||
- `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`.
|
||||
- JavaScript syntax: `node --check web/app.js` and `web/sw.js`.
|
||||
- JSON syntax for EN/PL language files and the PWA manifest.
|
||||
- EN/PL translation-key parity (300 keys each).
|
||||
- Shell syntax for all scripts in `scripts/`.
|
||||
- Python syntax for the Home Assistant migration generator.
|
||||
- HTML parsing and CSS brace-balance checks.
|
||||
- SQLite schema creation plus insert/throttle/read tests for `zone_readings`.
|
||||
- SQL centralization check: new SQL statements are in `src/queries.rs`.
|
||||
- No Node/npm/Tailwind build files remain in the release tree.
|
||||
|
||||
## Rust build
|
||||
## Rust compilation
|
||||
|
||||
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.
|
||||
The packaging environment does not contain Cargo/Rust, so a full Rust compile could not be executed here. The LXC installer/updater still runs `cargo test --all-targets` before replacing the installed binary, unless `--skip-tests` is explicitly used.
|
||||
|
||||
## Expected LXC update
|
||||
## Recommended LXC update
|
||||
|
||||
```bash
|
||||
unzip gree-controller-v0.4.1.zip
|
||||
unzip gree-controller-v0.4.2.zip
|
||||
cd gree-controller
|
||||
sudo ./scripts/update.sh
|
||||
```
|
||||
|
||||
Runtime configuration and SQLite data remain under `/etc/gree-controller.env` and `/var/lib/gree-controller/` and are preserved by the updater.
|
||||
The updater preserves `/etc/gree-controller.env` and `/var/lib/gree-controller/gree-controller.db` and rolls back automatically if the new service fails its health check.
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+24
-28
@@ -1,15 +1,15 @@
|
||||
da945a594f47b2b0c326cc0275b10a23cb509f545a1841ba9c57f00086e553ff ./.env.example
|
||||
aa544ed9f25f6db239c53472118440ed58a588236d989d21e5c95279371b8025 ./.gitignore
|
||||
f9ddac288965a3726f82f7c1b14949102a385842e305e407d5e8ca379ea30d7a ./BUILD_REPORT.md
|
||||
157c32f532d7daf8035381c724bb44455e5a2b4749bd83bd5304fba6fdc292b3 ./Cargo.toml
|
||||
17780b6416a709efae772c37fb1151a35e51e6e8e6f428566d2f3eb308a4c58d ./.gitignore
|
||||
c7adcd3ac665fe6a769519a2eb61dd96a494146c05708cab557238620e25368d ./BUILD_REPORT.md
|
||||
963046cc499a783fd381006051513c0f7138097f793e7d2e8c44417192dc4b99 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
4a40aadbeeb73677932b7ce374a56fd00f0eef4f7cfda68f102fc0e6f69602e4 ./README.md
|
||||
53c95efcdd17d4d21aefbed2154d6eef3697d3e9757a11fcce72948693cfb84b ./README.md
|
||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
||||
f0a1c6e7c932c3ceeccfc1c86e071966f7d52d0766e58105609e4de5674e7a78 ./docs/API.md
|
||||
0fb1606ecf67d7c73c53c4a9011d7ece4f2aa3b678eefeb446c4af5c28f91295 ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||
42bc9498478c2944ab3b2bdff6a09a37a2ffcdda1bff0bf259749886768b2c53 ./docs/PROJECT_SPEC.md
|
||||
ccfc7bc12d2b7e3b8d85c395e2eb37c991c2c3a3f901759cc0d2b5b0e4304b82 ./docs/PROJECT_SPEC.md
|
||||
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
e0cf725c9f84be51cdbd83a5ab12b2b8288f90b36623cb4671cefcbf04378504 ./home-assistant/custom_components/gree_controller/api.py
|
||||
@@ -18,45 +18,41 @@ fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assista
|
||||
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
|
||||
1d6057dfb31c306e2d23397b549eb5c0d19e412055b2aac4c7587a0baedb8522 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
ec45d55405c18631a66dc02baa924011dcc42d1e51bf5e209677ec6ce7a8282b ./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
|
||||
85f9562a7b2cc4a95836f76d8c02dd8a6689d675e778427416d18f70ad20ae22 ./lang/en.json
|
||||
81077c3015d026ecc3150bed6351d97079aef121c9994a5a99a7c44226c8100a ./lang/pl.json
|
||||
09739bb5f96f4a6f1124a2c4fa6bd7f91ebc30b3b38f19c2759647374aefb7a9 ./package.json
|
||||
39a34270e0ba51f0ff2e4b6d3990754857d285471a69430c5c07ce46ebbb82b6 ./scripts/README.md
|
||||
08a77150c28a97da0bbd7278f59ecbdfa278c4741b902af6aae2d7fffb9e9138 ./scripts/build-web.sh
|
||||
2a49c0cbcd1f2bad2128193c811168768c4d96886f701f3e03e75e2a64a4c657 ./scripts/common.sh
|
||||
c871974574a31e6c90e3572c6cae73fd014a9b2dffd486936bcf1a93727eb95d ./lang/en.json
|
||||
27e7edd19b2010e6ab71ccb397cf94e99e390d9caca3eac83000d558d871b990 ./lang/pl.json
|
||||
896c553a9e2a6de6ce6f69ae50b3eb78c0803bbc9573eb841440d98955eec919 ./scripts/README.md
|
||||
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
05340b11ffa6522ad7302c48d38da3aa3637a1f63b64e6c6dd4c24e254c18db2 ./scripts/dev.sh
|
||||
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
|
||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||
84ec7786cc37b0421a3358024af50151624131ac1dd0852a5bfd2897116d3eda ./scripts/install.sh
|
||||
cae0900fbc67e7cea311c4bd334d774f339092e92721c9c2b1570ca91920a53a ./scripts/install.sh
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
69a1c2ad30685cac517eeb18a27d40368506dc5e54b57d943c324339303afc2b ./scripts/smoke.sh
|
||||
51c7a18547c39cae7eb04ff65b6910264763481ebc2e4ad696e44ff56e520d55 ./scripts/update.sh
|
||||
a30e43d12685bb6351fd845a0b345b6c9eb931bcb4d9abec994c8d3ce5b99db6 ./src/api.rs
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
9620b0d32a24ec8111b46f34f4502565dfc601896ef92ba9be74fc25a4bd020f ./src/api.rs
|
||||
5138f9762540da497360667bd93e5e7274515a37baf3ca3c19c38b23a57c9e2b ./src/config.rs
|
||||
e58cc12a0343c089cceb722aac18162052c2a74ed25ac883dcc5def92d50e65b ./src/db.rs
|
||||
3c7dea320a9fd2535f195f63b5a17cebec4a1677b5fc247b834f8a14a8de9a39 ./src/engine.rs
|
||||
c68ec99a0dd26d3a49a9d14974af3bdca106aaebaf737a9d76a811850d7ecb53 ./src/db.rs
|
||||
3f5ffc1ff1fabab5952506d26349f37c23ea270e1351f0e8579a920c1bbc0f84 ./src/engine.rs
|
||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
|
||||
8f4fa47395451b2d9ee6270aae63dd8e7df9fa74182a3429b10aadac1029b090 ./src/main.rs
|
||||
ee6968b187f3782cfbf27e2f39a0e07187dc58b66ac8b4ac96f340b62c8f42ee ./src/models.rs
|
||||
150893b11ef45ddecc17302ece8dbcb77dd3d1fe4dc67bf8f2b75ead95c13fea ./src/models.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
b66ae7c0186b6cc3939d922b0a83f763fd670662c170c97cebca8235d99f663c ./src/protocol/gree.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
c80164631bba476db29469fa43b28d73f68412c06bafabe2143ab8d4cd330a52 ./src/queries.rs
|
||||
a27f64422712934b6d550a7dc6e520ff9f89099ecbcab2594169f0bd97052318 ./src/queries.rs
|
||||
3bd56018114a7199082e9e9d973107b73cb8259693a80743f4022364cda62095 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
24f1a398b6a31f337a8d807fba133f518334a918598a6003ea52b158b0daba41 ./tailwind.config.js
|
||||
36d92e4b23859e1d857bd6b95fc0a67b7b96cfd31f90e4a37c5e9c4bfaf8d456 ./web/app.js
|
||||
7b2f7054ab62d33fe9f43c9cab5056b3de2e4c3196e168f8669a640f5bf2d77b ./web/app.js
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
88d425fe7545c85ada1228ced4bec5e86194ed7abe03a24da4738add8849a809 ./web/index.html
|
||||
07a9405ad577d56fda37c51ad0d8f4ff7bff176727ccea45eb52cc8be23af8e5 ./web/index.html
|
||||
6db5f01ef1284c6de175c411fb7014a7b312436bc5964ee8086ed94650047b8c ./web/manifest.webmanifest
|
||||
d31c82797e155bd72795fefc605ffc208ac80785c3b3580c6f3301cbffc2146b ./web/styles.css
|
||||
7c4eb0cb8e89cdb219e8f7d1cb77c754184a25cfd2334da8a507d8e85724d95d ./web/sw.js
|
||||
6afdbfa97801477ec1e7d361444bb5ddc20478ce8a2ce1be49869d4cb07fbd92 ./web/tailwind.input.css
|
||||
b2889d002d1c345b554a69279081754ed4b984bb98431819a9f384f79b297b8d ./web/styles.css
|
||||
ae6af783cd2830e899494eda419b2310496ef75301b88d8ef6fa5ad5ca1e886b ./web/sw.js
|
||||
|
||||
@@ -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.4.1**.
|
||||
Current version: **0.4.2**.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -39,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.4.1.zip
|
||||
unzip gree-controller-v0.4.2.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -85,18 +85,12 @@ Appearance selector:
|
||||
- Light,
|
||||
- Dark.
|
||||
|
||||
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.
|
||||
The selected appearance is stored in the `gree_controller_theme` cookie. Version 0.4.2 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
|
||||
### Static offline CSS
|
||||
|
||||
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:
|
||||
The Supabase/Tailwind-inspired interface is shipped as a normal, committed `web/styles.css` file and embedded directly into the Rust binary. There is no Node.js, npm, Tailwind CLI, CDN or CSS build step. Editing the UI means editing `web/styles.css` and `web/index.html` directly, then rebuilding the Rust application.
|
||||
|
||||
```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
|
||||
|
||||
@@ -164,6 +158,10 @@ Use `--skip-tests` with `install.sh` or `update.sh` only when you explicitly wan
|
||||
|
||||
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.
|
||||
|
||||
## Climate history
|
||||
|
||||
History is stored per zone so one timeline can include the complete control context: GREE indoor temperature, the optional Home Assistant room sensor, calculated control temperature, comfort/profile target, actual device setpoint, outdoor HA temperature, demand, power, mode and fan speed. The History screen supports a detailed single-zone view and an **All zones** comparison chart for room/control temperatures and targets. Rich zone samples are throttled to at least 15 seconds (or the configured device poll interval, whichever is longer) to keep SQLite compact.
|
||||
|
||||
## Per-zone room temperature sensors
|
||||
|
||||
Each control zone can pair one GREE indoor unit with its own optional room sensor from Home Assistant. This is intentionally configured per zone, so rooms do not share a global temperature source.
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
MODE="debug"
|
||||
ACTION="run"
|
||||
INSTALL_DEPS=1
|
||||
RESET_DB=0
|
||||
HOST=""
|
||||
PORT=""
|
||||
|
||||
usage() {
|
||||
cat <<'TXT'
|
||||
GREE Controller - development environment
|
||||
|
||||
Usage:
|
||||
./dev.sh install missing tools, build and run
|
||||
./dev.sh --release run an optimized build
|
||||
./dev.sh --check formatting, tests, build and API smoke test
|
||||
./dev.sh --reset remove the local database before startup
|
||||
./dev.sh --no-install do not install system packages or Rust
|
||||
./dev.sh --host 0.0.0.0 --port 8787
|
||||
TXT
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--release) MODE="release"; shift ;;
|
||||
--check) ACTION="check"; shift ;;
|
||||
--reset) RESET_DB=1; shift ;;
|
||||
--no-install) INSTALL_DEPS=0; shift ;;
|
||||
--host) HOST="${2:?missing value for --host}"; shift 2 ;;
|
||||
--port) PORT="${2:?missing value for --port}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
run_privileged() {
|
||||
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then "$@"
|
||||
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
|
||||
else fail "Root/sudo privileges are required to install dependencies: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
install_build_tools() {
|
||||
[[ "$INSTALL_DEPS" -eq 1 ]] || return 0
|
||||
local missing=0
|
||||
command -v curl >/dev/null 2>&1 || missing=1
|
||||
command -v cc >/dev/null 2>&1 || missing=1
|
||||
if [[ "$missing" -eq 0 ]]; then return 0; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
say "Installing build packages (Debian/Ubuntu/LXC)"
|
||||
run_privileged apt-get update
|
||||
run_privileged apt-get install -y --no-install-recommends build-essential curl ca-certificates pkg-config
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
say "Installing build packages (Fedora/RHEL)"
|
||||
run_privileged dnf install -y gcc gcc-c++ make curl ca-certificates pkgconf-pkg-config
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
say "Installing build packages (Alpine)"
|
||||
run_privileged apk add --no-cache build-base curl ca-certificates pkgconf
|
||||
else
|
||||
fail "Unsupported package manager. Install a C compiler, make, curl and CA certificates."
|
||||
fi
|
||||
}
|
||||
|
||||
install_rust() {
|
||||
if command -v cargo >/dev/null 2>&1; then return 0; fi
|
||||
[[ "$INSTALL_DEPS" -eq 1 ]] || fail "Cargo is not installed and --no-install was requested"
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required to install Rust"
|
||||
say "Installing stable Rust with rustup"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
# shellcheck disable=SC1090
|
||||
source "$HOME/.cargo/env"
|
||||
}
|
||||
|
||||
install_build_tools
|
||||
install_rust
|
||||
command -v cargo >/dev/null 2>&1 || fail "Cargo is still unavailable"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
say "Created .env from the example configuration"
|
||||
fi
|
||||
mkdir -p data
|
||||
if [[ "$RESET_DB" -eq 1 ]]; then
|
||||
rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal
|
||||
say "Removed the local database"
|
||||
fi
|
||||
|
||||
# Export simple KEY=VALUE entries from .env.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source ./.env
|
||||
set +a
|
||||
|
||||
if [[ -n "$HOST" || -n "$PORT" ]]; then
|
||||
current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
|
||||
current_host="${current%:*}"
|
||||
current_port="${current##*:}"
|
||||
export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}"
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "check" ]]; then
|
||||
say "Checking formatting"
|
||||
cargo fmt --all -- --check
|
||||
say "Running Rust tests"
|
||||
cargo test --all-targets
|
||||
say "Building the application"
|
||||
cargo build
|
||||
say "Running HTTP/API smoke test"
|
||||
./scripts/smoke.sh
|
||||
say "All checks passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "release" ]]; then
|
||||
say "Building release version"
|
||||
cargo build --release
|
||||
BINARY="$ROOT/target/release/gree-controller"
|
||||
else
|
||||
say "Building debug version"
|
||||
cargo build
|
||||
BINARY="$ROOT/target/debug/gree-controller"
|
||||
fi
|
||||
|
||||
bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
|
||||
display_host="${bind%:*}"
|
||||
[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1"
|
||||
say "Panel: http://${display_host}:${bind##*:}"
|
||||
say "Stop: Ctrl+C"
|
||||
exec "$BINARY"
|
||||
+12
@@ -173,3 +173,15 @@ curl "$BASE/lang/en.json"
|
||||
```
|
||||
|
||||
`GET /lang/index.json` returns the automatically generated language catalog. `GET /lang/<code>.json` returns the corresponding language pack. Add a valid `lang/<code>.json` file and rebuild to expose a new language.
|
||||
|
||||
### `GET /api/history`
|
||||
|
||||
Returns rich per-zone climate history. Query parameters:
|
||||
|
||||
- `zone_id=<zone id>` for one zone, or `zone_id=all` for all zones,
|
||||
- `hours=6|24|168|720`,
|
||||
- `limit` up to 20,000 rows.
|
||||
|
||||
Each row contains `gree_temperature`, `external_temperature` (HA room sensor), `control_temperature`, `target_temperature`, `device_setpoint`, `outdoor_temperature`, `power`, `mode`, `fan_speed`, `demand`, `control_source`, and `active_preset`.
|
||||
|
||||
The endpoint automatically downsamples in SQLite to keep charts responsive: 30-second buckets up to 6 hours, 2-minute buckets up to 24 hours, 10-minute buckets up to 7 days, and 30-minute buckets for longer ranges.
|
||||
|
||||
@@ -175,8 +175,12 @@ The zone controller supports `device`, `combined`, and `home_assistant` temperat
|
||||
- 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)
|
||||
## Web UI design system (v0.4.2)
|
||||
|
||||
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`.
|
||||
The UI uses a Supabase/Tailwind-inspired visual language but ships as a normal static `web/styles.css` file embedded into the Rust binary. There is no Node.js, npm, Tailwind CLI, CDN, or CSS build pipeline.
|
||||
|
||||
### Rich zone history
|
||||
|
||||
Zone history stores GREE temperature, optional Home Assistant room temperature, calculated control temperature, profile target, actual device setpoint, outdoor temperature assist, power, mode, fan speed, demand, active preset and control source. The UI can compare all zones on common temperature/target charts or inspect one zone in detail.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Run as root: sudo ./install-lxc.sh" >&2; exit 1; }
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends build-essential curl ca-certificates pkg-config
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
source /root/.cargo/env
|
||||
fi
|
||||
cargo build --release
|
||||
|
||||
id gree-controller >/dev/null 2>&1 || useradd --system --home /var/lib/gree-controller --shell /usr/sbin/nologin gree-controller
|
||||
install -d -o gree-controller -g gree-controller -m 0750 /var/lib/gree-controller
|
||||
install -d -o root -g root -m 0755 /opt/gree-controller
|
||||
install -o root -g root -m 0755 target/release/gree-controller /opt/gree-controller/gree-controller
|
||||
install -o root -g root -m 0644 systemd/gree-controller.service /etc/systemd/system/gree-controller.service
|
||||
|
||||
if [[ ! -f /etc/gree-controller.env ]]; then
|
||||
token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
cat > /etc/gree-controller.env <<ENV
|
||||
GREE_CONTROLLER_BIND=0.0.0.0:8787
|
||||
GREE_CONTROLLER_DATABASE=/var/lib/gree-controller/gree-controller.db
|
||||
GREE_CONTROLLER_APP_TOKEN=$token
|
||||
GREE_CONTROLLER_SIMULATE=true
|
||||
GREE_CONTROLLER_AUTO_SEED=true
|
||||
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_ID=gree-controller
|
||||
RUST_LOG=info,tower_http=info
|
||||
HA_URL=
|
||||
HA_TOKEN=
|
||||
HA_ENTITY_ID=
|
||||
ENV
|
||||
chmod 0600 /etc/gree-controller.env
|
||||
echo "Generated API token: $token"
|
||||
echo "Saved to /etc/gree-controller.env"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gree-controller.service
|
||||
systemctl --no-pager --full status gree-controller.service || true
|
||||
ip="$(hostname -I | awk '{print $1}')"
|
||||
echo "Panel: http://${ip:-LXC_ADDRESS}:8787"
|
||||
echo "Logs: journalctl -u gree-controller -f"
|
||||
+20
-2
@@ -125,7 +125,7 @@
|
||||
"automations.emptyTitle": "No automations",
|
||||
"automations.emptyText": "Add a temperature or time rule.",
|
||||
"history.measurements": "Measurements",
|
||||
"history.title": "Temperature history",
|
||||
"history.title": "Climate history",
|
||||
"history.6h": "6 hours",
|
||||
"history.24h": "24 hours",
|
||||
"history.7d": "7 days",
|
||||
@@ -287,6 +287,24 @@
|
||||
"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."
|
||||
"automations.smartHint": "Schedules and zone presets handle normal heating/cooling automatically. Keep automations for special exceptions so they do not fight the thermostat.",
|
||||
"history.allZones": "All zones",
|
||||
"history.temperatureOverview": "Temperature overview",
|
||||
"history.temperatureOverviewHint": "GREE, room sensor, control temperature, targets and outdoor temperature.",
|
||||
"history.operationOverview": "Targets and operation",
|
||||
"history.operationOverviewHint": "Zone targets, compressor demand, power and fan activity over the same period.",
|
||||
"history.greeSensor": "GREE sensor",
|
||||
"history.roomSensor": "Room sensor",
|
||||
"history.controlTemperature": "Control temperature",
|
||||
"history.comfortTarget": "Comfort target",
|
||||
"history.deviceSetpoint": "Device setpoint",
|
||||
"history.outdoorTemperature": "Outdoor temperature",
|
||||
"history.fanSpeed": "Fan speed",
|
||||
"history.demand": "Demand",
|
||||
"history.power": "Power",
|
||||
"history.targetShort": "Target",
|
||||
"history.sensor": "Indoor unit",
|
||||
"history.homeAssistant": "Home Assistant",
|
||||
"history.assist": "Outdoor assist"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-2
@@ -125,7 +125,7 @@
|
||||
"automations.emptyTitle": "Brak automatyzacji",
|
||||
"automations.emptyText": "Dodaj regułę temperatury lub czasu.",
|
||||
"history.measurements": "Pomiary",
|
||||
"history.title": "Historia temperatury",
|
||||
"history.title": "Historia klimatu",
|
||||
"history.6h": "6 godzin",
|
||||
"history.24h": "24 godziny",
|
||||
"history.7d": "7 dni",
|
||||
@@ -287,6 +287,24 @@
|
||||
"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."
|
||||
"automations.smartHint": "Harmonogramy i presety stref obsługują normalne grzanie/chłodzenie automatycznie. Automatyzacje zostaw na wyjątki, aby nie walczyły z termostatem.",
|
||||
"history.allZones": "Wszystkie strefy",
|
||||
"history.temperatureOverview": "Przegląd temperatur",
|
||||
"history.temperatureOverviewHint": "GREE, czujnik pokojowy, temperatura sterująca, cele i temperatura zewnętrzna.",
|
||||
"history.operationOverview": "Cele i praca urządzenia",
|
||||
"history.operationOverviewHint": "Cele stref, zapotrzebowanie, zasilanie i nawiew w tym samym okresie.",
|
||||
"history.greeSensor": "Czujnik GREE",
|
||||
"history.roomSensor": "Czujnik pokojowy",
|
||||
"history.controlTemperature": "Temperatura sterująca",
|
||||
"history.comfortTarget": "Cel komfortu",
|
||||
"history.deviceSetpoint": "Nastawa jednostki",
|
||||
"history.outdoorTemperature": "Temperatura zewnętrzna",
|
||||
"history.fanSpeed": "Prędkość nawiewu",
|
||||
"history.demand": "Zapotrzebowanie",
|
||||
"history.power": "Zasilanie",
|
||||
"history.targetShort": "Cel",
|
||||
"history.sensor": "Jednostka wewnętrzna",
|
||||
"history.homeAssistant": "Home Assistant",
|
||||
"history.assist": "Wsparcie pogodowe"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -6,7 +6,6 @@ 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`.
|
||||
@@ -17,4 +16,4 @@ 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.
|
||||
The running controller never uses a CDN or Node.js. `web/styles.css` is a normal static CSS file embedded into the Rust binary; there is no CSS build step.
|
||||
|
||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
#!/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,14 +60,6 @@ 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
|
||||
|
||||
@@ -46,7 +46,6 @@ 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
|
||||
|
||||
@@ -38,7 +38,6 @@ 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)"
|
||||
|
||||
@@ -45,6 +45,11 @@ curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Test","device_id":"sim-salon","enabled":true,"mode":"cool","setpoint":23,"hysteresis":0.6,"min_on_seconds":0,"min_off_seconds":0,"sensor_source":"device"}' \
|
||||
"http://127.0.0.1:$PORT/api/zones" >"$TMP/zone.json"
|
||||
grep -q '"name":"Test"' "$TMP/zone.json"
|
||||
ZONE_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$TMP/zone.json")"
|
||||
sleep 3
|
||||
curl -fsS "http://127.0.0.1:$PORT/api/history?zone_id=$ZONE_ID&hours=1" >"$TMP/history.json"
|
||||
grep -q '"readings"' "$TMP/history.json"
|
||||
grep -q '"control_temperature"' "$TMP/history.json"
|
||||
|
||||
curl -fsS "http://127.0.0.1:$PORT/api/readings?device_id=sim-salon&hours=1" >"$TMP/readings.json"
|
||||
grep -q '"readings"' "$TMP/readings.json"
|
||||
|
||||
@@ -40,7 +40,6 @@ require_systemd
|
||||
|
||||
install_build_dependencies
|
||||
ensure_rust
|
||||
build_web_assets_if_available
|
||||
version="$(project_version)"
|
||||
say "Preparing update to GREE Controller ${version:-unknown}"
|
||||
|
||||
|
||||
+21
@@ -55,6 +55,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/automations", get(list_automations).post(create_automation))
|
||||
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
|
||||
.route("/api/readings", get(readings))
|
||||
.route("/api/history", get(history))
|
||||
.route("/api/events", get(events))
|
||||
.route("/api/settings", get(get_settings).put(update_settings))
|
||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||
@@ -730,6 +731,26 @@ async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQue
|
||||
Ok(Json(json!({"readings": values})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HistoryQuery { zone_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
|
||||
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
|
||||
let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
if let Some(zone_id) = zone_id {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::NotFound(format!("zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
let bucket_seconds = match hours {
|
||||
1..=6 => 30,
|
||||
7..=24 => 120,
|
||||
25..=168 => 600,
|
||||
_ => 1800,
|
||||
};
|
||||
let values = state.db.list_zone_history(zone_id, Utc::now() - ChronoDuration::hours(hours), bucket_seconds, query.limit.unwrap_or(12_000))?;
|
||||
Ok(Json(json!({"readings": values, "bucket_seconds": bucket_seconds})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsQuery { limit: Option<u32> }
|
||||
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone},
|
||||
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
queries,
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ impl Db {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
@@ -103,6 +104,7 @@ impl Db {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_ZONE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
@@ -236,7 +238,93 @@ impl Db {
|
||||
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64)
|
||||
let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
Ok(device + zone)
|
||||
}
|
||||
|
||||
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
queries::INSERT_ZONE_READING_IF_DUE,
|
||||
params![
|
||||
reading.zone_id,
|
||||
reading.device_id,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.gree_temperature,
|
||||
reading.external_temperature,
|
||||
reading.control_temperature,
|
||||
reading.target_temperature,
|
||||
reading.device_setpoint,
|
||||
reading.outdoor_temperature,
|
||||
reading.power as i64,
|
||||
reading.mode,
|
||||
reading.fan_speed as i64,
|
||||
reading.demand as i64,
|
||||
reading.control_source,
|
||||
reading.active_preset,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_zone_readings(&self, zone_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<ZoneReading>> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(zone_id) = zone_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_READINGS_BY_ZONE)?;
|
||||
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_READINGS_ALL)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<ZoneReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(zone_id) = zone_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_zone_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<ZoneReading> {
|
||||
let timestamp: String = row.get(3)?;
|
||||
Ok(ZoneReading {
|
||||
id: row.get(0)?,
|
||||
zone_id: row.get(1)?,
|
||||
device_id: row.get(2)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
gree_temperature: row.get(4)?,
|
||||
external_temperature: row.get(5)?,
|
||||
control_temperature: row.get(6)?,
|
||||
target_temperature: row.get(7)?,
|
||||
device_setpoint: row.get(8)?,
|
||||
outdoor_temperature: row.get(9)?,
|
||||
power: row.get::<_, i64>(10)? != 0,
|
||||
mode: row.get(11)?,
|
||||
fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8,
|
||||
demand: row.get::<_, i64>(13)? != 0,
|
||||
control_source: row.get(14)?,
|
||||
active_preset: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
|
||||
+30
-1
@@ -6,7 +6,7 @@ use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone},
|
||||
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone, ZoneReading},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -341,6 +341,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
@@ -352,6 +353,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
let Some(temp) = temperature else {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
@@ -422,12 +424,39 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) -> Result<()> {
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { return Ok(()); };
|
||||
let reading = ZoneReading {
|
||||
id: 0,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
gree_temperature: zone.device_temperature,
|
||||
external_temperature: zone.external_temperature,
|
||||
control_temperature: zone.current_temperature,
|
||||
target_temperature: zone.effective_setpoint,
|
||||
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
|
||||
outdoor_temperature,
|
||||
power: device.power,
|
||||
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
|
||||
fan_speed: device.fan_speed,
|
||||
demand: zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
active_preset: zone.active_preset.clone(),
|
||||
};
|
||||
// History is deliberately less frequent than the zone control loop to keep SQLite compact.
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
state.db.add_zone_reading_if_due(&reading, interval)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
|
||||
match zone.sensor_source.as_str() {
|
||||
"home_assistant" => match (external_temperature, device_temperature) {
|
||||
|
||||
@@ -323,6 +323,26 @@ pub struct Reading {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZoneReading {
|
||||
pub id: i64,
|
||||
pub zone_id: String,
|
||||
pub device_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub gree_temperature: Option<f64>,
|
||||
pub external_temperature: Option<f64>,
|
||||
pub control_temperature: Option<f64>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub device_setpoint: Option<f64>,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub power: bool,
|
||||
pub mode: String,
|
||||
pub fan_speed: u8,
|
||||
pub demand: bool,
|
||||
pub control_source: String,
|
||||
pub active_preset: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventLog {
|
||||
pub id: i64,
|
||||
|
||||
@@ -63,6 +63,29 @@ CREATE TABLE IF NOT EXISTS readings (
|
||||
CREATE INDEX IF NOT EXISTS readings_device_time_idx
|
||||
ON readings(device_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zone_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
zone_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
gree_temperature REAL,
|
||||
external_temperature REAL,
|
||||
control_temperature REAL,
|
||||
target_temperature REAL,
|
||||
device_setpoint REAL,
|
||||
outdoor_temperature REAL,
|
||||
power INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
fan_speed INTEGER NOT NULL,
|
||||
demand INTEGER NOT NULL,
|
||||
control_source TEXT NOT NULL,
|
||||
active_preset TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx
|
||||
ON zone_readings(zone_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_time_idx
|
||||
ON zone_readings(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
@@ -85,6 +108,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
@@ -175,6 +200,68 @@ LIMIT ?2
|
||||
|
||||
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
|
||||
INSERT INTO zone_readings(
|
||||
zone_id, device_id, timestamp, gree_temperature, external_temperature,
|
||||
control_temperature, target_temperature, device_setpoint, outdoor_temperature,
|
||||
power, mode, fan_speed, demand, control_source, active_preset
|
||||
)
|
||||
SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zone_readings WHERE zone_id=?1 AND timestamp>=?16 LIMIT 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const LIST_ZONE_READINGS_BY_ZONE: &str = r#"
|
||||
SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,
|
||||
control_temperature,target_temperature,device_setpoint,outdoor_temperature,
|
||||
power,mode,fan_speed,demand,control_source,active_preset
|
||||
FROM zone_readings
|
||||
WHERE zone_id=?1 AND timestamp>=?2
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const LIST_ZONE_READINGS_ALL: &str = r#"
|
||||
SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,
|
||||
control_temperature,target_temperature,device_setpoint,outdoor_temperature,
|
||||
power,mode,fan_speed,demand,control_source,active_preset
|
||||
FROM zone_readings
|
||||
WHERE timestamp>=?1
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?2
|
||||
"#;
|
||||
|
||||
pub const LIST_ZONE_HISTORY_BY_ZONE_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
|
||||
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
|
||||
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
|
||||
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
|
||||
MAX(control_source),MAX(active_preset)
|
||||
FROM zone_readings
|
||||
WHERE zone_id=?1 AND timestamp>=?2
|
||||
GROUP BY zone_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_ZONE_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
|
||||
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
|
||||
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
|
||||
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
|
||||
MAX(control_source),MAX(active_preset)
|
||||
FROM zone_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY zone_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1";
|
||||
pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_EVENT: &str =
|
||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const LIST_EVENTS: &str =
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/** @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: []
|
||||
};
|
||||
+167
-35
@@ -16,7 +16,7 @@ const app = {
|
||||
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: {},
|
||||
languages: [], translations: {}, locales: {}, historyReadings: [], historyZone: 'all',
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
@@ -28,7 +28,8 @@ const tr = (key, params = {}) => {
|
||||
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key;
|
||||
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
|
||||
};
|
||||
const fmtTemp = value => Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
|
||||
const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
|
||||
const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value);
|
||||
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
|
||||
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
|
||||
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
|
||||
@@ -59,7 +60,7 @@ function applyTranslations() {
|
||||
if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`);
|
||||
renderAll();
|
||||
if (app.currentView === 'logs') loadLogs();
|
||||
if (app.currentView === 'history' && app.devices.length) loadHistory();
|
||||
if (app.currentView === 'history' && app.zones.length) loadHistory();
|
||||
}
|
||||
|
||||
function setLanguage(language) {
|
||||
@@ -340,8 +341,12 @@ function fillSelects() {
|
||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||
const presetZone = $('#schedulePresetZone');
|
||||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
||||
const history = $('#historyDevice');
|
||||
if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; }
|
||||
const history = $('#historyZone');
|
||||
if (history) {
|
||||
const historyCurrent = history.value || 'all';
|
||||
history.innerHTML = `<option value="all">${esc(tr('history.allZones'))}</option>${zoneOptions}`;
|
||||
if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccessTokens() {
|
||||
@@ -383,7 +388,7 @@ function showView(name) {
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name))));
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
if (name === 'history' && app.devices.length) loadHistory();
|
||||
if (name === 'history' && app.zones.length) loadHistory();
|
||||
if (name === 'logs') loadLogs();
|
||||
}
|
||||
|
||||
@@ -492,47 +497,172 @@ function populateAutomation(id) {
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const deviceId = $('#historyDevice').value;
|
||||
if (!deviceId) return drawChart([]);
|
||||
const zoneId = $('#historyZone')?.value || 'all';
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
try {
|
||||
const data = await api(`/api/readings?device_id=${encodeURIComponent(deviceId)}&hours=${encodeURIComponent($('#historyHours').value)}&limit=2500`);
|
||||
drawChart(data.readings || []);
|
||||
const data = await api(`/api/history?zone_id=${encodeURIComponent(zoneId)}&hours=${encodeURIComponent(hours)}&limit=12000`);
|
||||
const readings = data.readings || [];
|
||||
app.historyReadings = readings; app.historyZone = zoneId;
|
||||
renderHistorySummary(readings, zoneId);
|
||||
drawHistoryCharts(readings, zoneId);
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
function drawChart(readings) {
|
||||
const canvas = $('#historyChart');
|
||||
const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue'];
|
||||
|
||||
function cssColor(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function latestByZone(readings) {
|
||||
const map = new Map();
|
||||
readings.forEach(row => map.set(row.zone_id, row));
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderHistorySummary(readings, zoneId) {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
if (!readings.length) {
|
||||
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('history.noData'))}</strong></div>`;
|
||||
return;
|
||||
}
|
||||
if (zoneId === 'all') {
|
||||
const latest = latestByZone(readings);
|
||||
host.innerHTML = app.zones.filter(z => latest.has(z.id)).map(zone => {
|
||||
const row = latest.get(zone.id);
|
||||
return `<div class="history-stat"><small>${esc(zone.name)}</small><strong>${fmtTemp(row.control_temperature)}</strong><span>${esc(tr('history.targetShort'))} ${fmtTemp(row.target_temperature)}</span></div>`;
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
const row = readings[readings.length - 1];
|
||||
const cards = [
|
||||
[tr('history.greeSensor'), fmtTemp(row.gree_temperature), tr('history.sensor')],
|
||||
[tr('history.roomSensor'), fmtTemp(row.external_temperature), tr('history.homeAssistant')],
|
||||
[tr('history.controlTemperature'), fmtTemp(row.control_temperature), row.control_source || '—'],
|
||||
[tr('history.comfortTarget'), fmtTemp(row.target_temperature), row.active_preset || '—'],
|
||||
[tr('history.deviceSetpoint'), fmtTemp(row.device_setpoint), (row.mode || '—').toUpperCase()],
|
||||
[tr('history.outdoorTemperature'), fmtTemp(row.outdoor_temperature), tr('history.assist')],
|
||||
];
|
||||
host.innerHTML = cards.map(([label,value,detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function timeLabel(ts, hours) {
|
||||
const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'};
|
||||
return new Date(ts).toLocaleString(locale(), options);
|
||||
}
|
||||
|
||||
function prepareCanvas(canvas, height) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(680, Math.floor(rect.width || 680));
|
||||
const height = 340;
|
||||
const width = Math.max(720, Math.floor(rect.width || 720));
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr; canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const text = styles.getPropertyValue('--muted').trim(), grid = styles.getPropertyValue('--grid').trim(), indoor = styles.getPropertyValue('--accent').trim(), target = styles.getPropertyValue('--warning').trim();
|
||||
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
ctx.clearRect(0,0,width,height);
|
||||
const pad = {left:52,right:20,top:22,bottom:40};
|
||||
if (!readings.length) { ctx.fillStyle = text; ctx.font = '14px system-ui'; ctx.textAlign='center'; ctx.fillText(tr('history.noData'), width/2, height/2); return; }
|
||||
const values = readings.flatMap(r => [r.indoor_temperature, r.target_temperature]).filter(Number.isFinite);
|
||||
let min = Math.floor(Math.min(...values) - 1), max = Math.ceil(Math.max(...values) + 1); if (max-min < 4) { min -= 2; max += 2; }
|
||||
const x = i => pad.left + i / Math.max(1, readings.length-1) * (width-pad.left-pad.right);
|
||||
return {ctx,width,height};
|
||||
}
|
||||
|
||||
function drawEmptyChart(canvas, height=340) {
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center';
|
||||
ctx.fillText(tr('history.noData'), width/2, height/2);
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) {
|
||||
if (!canvas || !rows.length || !series.length) return drawEmptyChart(canvas, height);
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'), panel=cssColor('--surface','#111');
|
||||
const pad={left:54,right:20,top:20,bottom:42};
|
||||
const allValues=[];
|
||||
series.forEach(s => rows.forEach(r => { const v=s.value(r); if(Number.isFinite(v)) allValues.push(v); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas,height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue;
|
||||
if (max-min < 2) { min-=1; max+=1; }
|
||||
const firstTs = new Date(rows[0].timestamp).getTime();
|
||||
const lastTs = new Date(rows[rows.length-1].timestamp).getTime();
|
||||
const span = Math.max(1,lastTs-firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right);
|
||||
const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom);
|
||||
ctx.lineWidth = 1; ctx.font = '11px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid;
|
||||
for (let i=0;i<=5;i++) { const value = min+(max-min)*i/5, py=y(value); ctx.beginPath(); ctx.moveTo(pad.left,py); ctx.lineTo(width-pad.right,py); ctx.stroke(); ctx.textAlign='right'; ctx.fillText(`${value.toFixed(1)}°`,pad.left-8,py+4); }
|
||||
const ticks = Math.min(5, readings.length-1);
|
||||
for (let i=0;i<=ticks;i++) { const idx=Math.round(i*(readings.length-1)/Math.max(1,ticks)), px=x(idx); ctx.textAlign='center'; ctx.fillText(new Date(readings[idx].timestamp).toLocaleString(locale(),{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),px,height-13); }
|
||||
function line(field, color, dashed=false) {
|
||||
ctx.beginPath(); ctx.strokeStyle=color; ctx.lineWidth=2.3; ctx.setLineDash(dashed?[6,5]:[]); let started=false;
|
||||
readings.forEach((row,index)=>{ const value=Number(row[field]); if(!Number.isFinite(value)) return; if(!started){ctx.moveTo(x(index),y(value));started=true;}else ctx.lineTo(x(index),y(value)); }); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1;
|
||||
for(let i=0;i<=5;i++){
|
||||
const value=min+(max-min)*i/5, py=y(value);
|
||||
ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke();
|
||||
ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3);
|
||||
}
|
||||
line('target_temperature', target, true); line('indoor_temperature', indoor, false);
|
||||
canvas._readings = readings;
|
||||
const ticks=5;
|
||||
for(let i=0;i<=ticks;i++){
|
||||
const idx=Math.min(rows.length-1,Math.round(i*(rows.length-1)/ticks)); const px=x(rows[idx]);
|
||||
ctx.textAlign='center'; ctx.fillText(timeLabel(rows[idx].timestamp,$('#historyHours')?.value),px,height-14);
|
||||
}
|
||||
series.forEach(s => {
|
||||
ctx.beginPath(); ctx.strokeStyle=s.color; ctx.lineWidth=s.width||2; ctx.setLineDash(s.dash||[]);
|
||||
let started=false, prev=null;
|
||||
rows.forEach(row => {
|
||||
const value=s.value(row); if(!Number.isFinite(value)) return;
|
||||
const px=x(row),py=y(value);
|
||||
if(!started||prev===null){ctx.moveTo(px,py);started=true;}
|
||||
else if(s.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
|
||||
prev=value;
|
||||
});
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
});
|
||||
canvas._history={rows,series};
|
||||
}
|
||||
|
||||
function renderLegend(host, series) {
|
||||
if (!host) return;
|
||||
host.innerHTML=series.map(s=>`<span><i class="legend-line" style="--legend-color:${esc(s.color)}"></i>${esc(s.label)}</span>`).join('');
|
||||
}
|
||||
|
||||
function drawHistoryCharts(readings, zoneId) {
|
||||
const tempCanvas=$('#historyTemperatureChart'), opCanvas=$('#historyOperationChart');
|
||||
if (!readings.length) {
|
||||
drawEmptyChart(tempCanvas,360); drawEmptyChart(opCanvas,260);
|
||||
$('#historyTemperatureLegend').innerHTML=''; $('#historyOperationLegend').innerHTML=''; return;
|
||||
}
|
||||
if (zoneId === 'all') {
|
||||
const zones=app.zones.filter(z=>readings.some(r=>r.zone_id===z.id));
|
||||
const rows=readings;
|
||||
const tempSeries=zones.map((zone,index)=>({
|
||||
label:zone.name,
|
||||
color:cssColor(HISTORY_COLORS[index%HISTORY_COLORS.length],`hsl(${index*67%360} 70% 55%)`),
|
||||
value:r=>r.zone_id===zone.id?historyNumber(r.control_temperature):NaN,
|
||||
}));
|
||||
const outdoorColor=cssColor('--muted-strong','#999');
|
||||
tempSeries.push({label:tr('history.outdoorTemperature'),color:outdoorColor,dash:[6,5],value:r=>historyNumber(r.outdoor_temperature)});
|
||||
drawLineChart(tempCanvas,tempSeries,rows,{height:360}); renderLegend($('#historyTemperatureLegend'),tempSeries);
|
||||
|
||||
const targetSeries=zones.map((zone,index)=>({
|
||||
label:`${zone.name} · ${tr('history.targetShort')}`,
|
||||
color:cssColor(HISTORY_COLORS[index%HISTORY_COLORS.length],`hsl(${index*67%360} 70% 55%)`),
|
||||
dash:[5,4], value:r=>r.zone_id===zone.id?historyNumber(r.target_temperature):NaN,
|
||||
}));
|
||||
drawLineChart(opCanvas,targetSeries,rows,{height:260}); renderLegend($('#historyOperationLegend'),targetSeries);
|
||||
return;
|
||||
}
|
||||
|
||||
const temperatureSeries=[
|
||||
{label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:r=>historyNumber(r.gree_temperature)},
|
||||
{label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:r=>historyNumber(r.external_temperature)},
|
||||
{label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:r=>historyNumber(r.control_temperature)},
|
||||
{label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:r=>historyNumber(r.target_temperature)},
|
||||
{label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:r=>historyNumber(r.device_setpoint)},
|
||||
{label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:r=>historyNumber(r.outdoor_temperature)},
|
||||
];
|
||||
drawLineChart(tempCanvas,temperatureSeries,readings,{height:360}); renderLegend($('#historyTemperatureLegend'),temperatureSeries);
|
||||
|
||||
const operationSeries=[
|
||||
{label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:r=>historyNumber(r.fan_speed)},
|
||||
{label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:r=>r.demand?4.5:0.5},
|
||||
{label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:r=>r.power?3.5:0.5},
|
||||
];
|
||||
drawLineChart(opCanvas,operationSeries,readings,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#historyOperationLegend'),operationSeries);
|
||||
}
|
||||
|
||||
function drawCurrentChartIfVisible() {
|
||||
const canvas = $('#historyChart');
|
||||
if (app.currentView === 'history' && canvas?._readings) drawChart(canvas._readings);
|
||||
if (app.currentView === 'history') drawHistoryCharts(app.historyReadings || [], app.historyZone || 'all');
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
@@ -638,7 +768,8 @@ $('#discoverButton').addEventListener('click', () => {
|
||||
openDialog('discoverDialog');
|
||||
});
|
||||
$('#historyRefresh').addEventListener('click', loadHistory);
|
||||
$('#historyDevice').addEventListener('change', loadHistory);
|
||||
$('#historyZone').addEventListener('change', loadHistory);
|
||||
$('#historyHours').addEventListener('change', loadHistory);
|
||||
$('#historyHours').addEventListener('change', loadHistory);
|
||||
$('#logsRefresh').addEventListener('click', loadLogs);
|
||||
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
||||
@@ -768,7 +899,8 @@ $('#haTest').addEventListener('click', async () => {
|
||||
} catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); });
|
||||
let historyResizeTimer;
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } });
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); });
|
||||
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{}));
|
||||
|
||||
|
||||
+13
-4
@@ -90,10 +90,10 @@
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="history">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Temperature history</h1></div></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Climate history</h1></div></div>
|
||||
<div class="panel chart-panel">
|
||||
<div class="chart-toolbar">
|
||||
<select id="historyDevice"></select>
|
||||
<select id="historyZone" aria-label="History zone"></select>
|
||||
<select id="historyHours">
|
||||
<option value="6" data-i18n="history.6h">6 hours</option>
|
||||
<option value="24" selected data-i18n="history.24h">24 hours</option>
|
||||
@@ -102,8 +102,17 @@
|
||||
</select>
|
||||
<button class="secondary" id="historyRefresh" data-i18n="actions.show">Show</button>
|
||||
</div>
|
||||
<div class="chart-wrap"><canvas id="historyChart" width="1000" height="420"></canvas></div>
|
||||
<div class="legend"><span><i class="dot indoor"></i><span data-i18n="common.temperature">Temperature</span></span><span><i class="dot target"></i><span data-i18n="common.target">Target</span></span></div>
|
||||
<div class="history-summary" id="historySummary"></div>
|
||||
</div>
|
||||
<div class="panel chart-panel history-chart-card">
|
||||
<div class="chart-title-row"><div><h3 data-i18n="history.temperatureOverview">Temperature overview</h3><p data-i18n="history.temperatureOverviewHint">GREE, room sensor, control temperature, targets and outdoor temperature.</p></div></div>
|
||||
<div class="chart-wrap"><canvas id="historyTemperatureChart" width="1000" height="420"></canvas></div>
|
||||
<div class="legend" id="historyTemperatureLegend"></div>
|
||||
</div>
|
||||
<div class="panel chart-panel history-chart-card">
|
||||
<div class="chart-title-row"><div><h3 data-i18n="history.operationOverview">Operation overview</h3><p data-i18n="history.operationOverviewHint">Demand, power and fan activity over the same period.</p></div></div>
|
||||
<div class="chart-wrap compact-chart"><canvas id="historyOperationChart" width="1000" height="300"></canvas></div>
|
||||
<div class="legend" id="historyOperationLegend"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+38
-7
@@ -25,6 +25,12 @@
|
||||
--danger-soft: rgba(248, 113, 113, .10);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, .10);
|
||||
--info: #60a5fa;
|
||||
--blue: #3b82f6;
|
||||
--teal: #2dd4bf;
|
||||
--purple: #a78bfa;
|
||||
--orange: #fb923c;
|
||||
--muted-strong: #b5b5bd;
|
||||
--backdrop: rgba(0, 0, 0, .68);
|
||||
--grid: rgba(255, 255, 255, .07);
|
||||
--scroll-thumb: #444444;
|
||||
@@ -62,6 +68,12 @@
|
||||
--danger-soft: rgba(220, 95, 95, .08);
|
||||
--warning: #b7791f;
|
||||
--warning-soft: rgba(183, 121, 31, .08);
|
||||
--info: #2563eb;
|
||||
--blue: #2563eb;
|
||||
--teal: #0f9f8f;
|
||||
--purple: #7c3aed;
|
||||
--orange: #c56b21;
|
||||
--muted-strong: #52525b;
|
||||
--backdrop: rgba(23, 23, 23, .32);
|
||||
--grid: rgba(24, 24, 27, .08);
|
||||
--scroll-thumb: #c8ccca;
|
||||
@@ -299,14 +311,27 @@ h3 { margin-bottom: 9px; font-size: 15px; font-weight: 620; letter-spacing: -.01
|
||||
|
||||
.panel { padding: 16px; }
|
||||
.chart-panel { overflow: hidden; }
|
||||
.history-chart-card { margin-top: 12px; }
|
||||
.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); }
|
||||
.chart-toolbar select { flex: 1; min-width: 160px; }
|
||||
.chart-title-row { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.chart-title-row h3 { margin:0 0 4px; font-size:14px; }
|
||||
.chart-title-row p { margin:0; color:var(--muted); font-size:11px; }
|
||||
.history-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||
.history-stat { min-width:0; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--surface-2); }
|
||||
.history-stat small,.history-stat strong,.history-stat span { display:block; }
|
||||
.history-stat small { color:var(--muted); font-size:10px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.history-stat strong { margin-top:4px; color:var(--text); font-size:17px; font-weight:620; }
|
||||
.history-stat span { margin-top:2px; color:var(--muted-2); font-size:9px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; scrollbar-width:none; -ms-overflow-style:none; }
|
||||
.chart-wrap::-webkit-scrollbar { display:none; }
|
||||
.chart-wrap.compact-chart { min-height:230px; }
|
||||
#historyTemperatureChart,#historyOperationChart { width:100%; min-width:720px; }
|
||||
#historyTemperatureChart { height:360px; }
|
||||
#historyOperationChart { height:260px; }
|
||||
.legend { display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:9px; color:var(--muted); font-size:10px; }
|
||||
.legend > span { display:flex; align-items:center; gap:6px; }
|
||||
.legend-line { width:16px; height:2px; border-radius:999px; background:var(--legend-color,var(--accent)); }
|
||||
|
||||
.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; }
|
||||
@@ -580,3 +605,9 @@ legend { padding: 0 5px; color: var(--muted); font-size: 10px; }
|
||||
.toolbar-select { min-width: 52px; }
|
||||
.theme-select { min-width: 78px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.history-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.chart-toolbar { display:grid; grid-template-columns:1fr 1fr; }
|
||||
.chart-toolbar button { grid-column:1/-1; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v041';
|
||||
const CACHE = 'gree-controller-v042';
|
||||
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())));
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
@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