This commit is contained in:
Mateusz Gruszczyński
2026-08-23 23:32:26 +02:00
parent b23578a0c8
commit f569b7db14
42 changed files with 597 additions and 4104 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
__pycache__
__pycache__/
# Rust build output
/target/
+29 -44
View File
@@ -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
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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
View File
@@ -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
+9 -11
View File
@@ -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.
View File
-138
View File
@@ -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
View File
@@ -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.
+6 -2
View File
@@ -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
@@ -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",
-49
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
-13
View File
@@ -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
View File
@@ -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.
-52
View File
@@ -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"
-8
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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)"
+5
View File
@@ -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"
-1
View File
@@ -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
View File
@@ -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> {
+90 -2
View File
@@ -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(&timestamp)
.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
View File
@@ -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) {
+20
View File
@@ -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,
+87
View File
@@ -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 =
-24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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())));
-587
View File
@@ -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; }
}