diff --git a/.gitignore b/.gitignore index 46b7703..f08e203 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -__pycache__ +__pycache__/ # Rust build output /target/ diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index d574fc1..138dd1f 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index a3728d6..a6b4c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.4.1" +version = "0.4.2" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 5f840aa..a72bc4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 9b4b7fe..a935c65 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 7abd739..d4d51cd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/dev.sh b/dev.sh deleted file mode 100755 index 36c65e1..0000000 --- a/dev.sh +++ /dev/null @@ -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" diff --git a/docs/API.md b/docs/API.md index 858762a..2db823d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -173,3 +173,15 @@ curl "$BASE/lang/en.json" ``` `GET /lang/index.json` returns the automatically generated language catalog. `GET /lang/.json` returns the corresponding language pack. Add a valid `lang/.json` file and rebuild to expose a new language. + +### `GET /api/history` + +Returns rich per-zone climate history. Query parameters: + +- `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. diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 88d93d8..3136e09 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -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. diff --git a/docs/original-spec.md b/docs/original-spec.md deleted file mode 100644 index ff85282..0000000 --- a/docs/original-spec.md +++ /dev/null @@ -1,3085 +0,0 @@ -Tak — i **to jest bardzo sensowny projekt**, szczególnie że nie musimy wymyślać protokołu od zera. GREE/EWPE ma lokalny protokół UDP, a obecna integracja Home Assistant również komunikuje się lokalnie. Oficjalna integracja HA jest typu *Local Polling* i obsługuje m.in. tryb HVAC, temperaturę, wentylator, swing i presety. - -Co ważniejsze, są już implementacje pokazujące, że protokół można obsługiwać niezależnie od Home Assistanta: UDP/7000, szyfrowanie AES, discovery/bind/status/command. Nowsze implementacje obsługują nawet AES-128-ECB i AES-128-GCM. - -### Ja zrobiłbym to trochę inaczej niż zwykły custom component - -Zaprojektowałbym **własny lokalny serwer GREE + integrację HA**, np.: - -``` -``` - -``` - ┌──────────────────────────┐ - │ Home Assistant │ - │ │ - │ climate.gree_salon │ - │ sensor.gree_temperature │ - │ switch.gree_quiet │ - │ ... │ - └────────────┬─────────────┘ - │ - REST / WebSocket - │ - ┌────────────▼─────────────┐ - │ Gree Controller │ - │ │ - │ Device Manager │ - │ State Manager │ - │ Automation Engine │ - │ Heating Manager │ - │ Scheduler │ - │ Web UI │ - └────────────┬─────────────┘ - │ - GREE UDP / AES - │ - ┌─────────────────────┼─────────────────────┐ - │ │ │ - ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ - │ GREE AC │ │ GREE AC │ │ GREE AC │ - │ salon │ │ sypial. │ │ biuro │ - └─────────┘ └─────────┘ └─────────┘ -``` - -Dzięki temu **HA nie jest miejscem, w którym znajduje się cała logika**. HA staje się klientem naszego kontrolera. - ---- - -## Najważniejsze: kompatybilność 1:1 - -Chciałbym zachować praktycznie ten sam model urządzenia, który HA już zna: - -### Climate - -- `HVAC_MODE_OFF` -- `HVAC_MODE_AUTO` -- `HVAC_MODE_COOL` -- `HVAC_MODE_HEAT` -- `HVAC_MODE_DRY` -- `HVAC_MODE_FAN_ONLY` -- temperatura zadana -- temperatura pomieszczenia -- prędkość wentylatora -- swing pionowy -- swing poziomy -- preset -- power - -Obecna integracja GREE obsługuje już te podstawowe funkcje. - -### Switch - -Np.: - -- panel light -- quiet -- X-Fan -- turbo -- health -- sleep -- child lock -- display -- eco - -Oczywiście tylko te, które faktycznie obsługuje konkretny model. - -### Dodatkowe sensory - -I tutaj możemy zrobić znacznie więcej: - -``` -``` - -``` -sensor.gree_salon_temperature -sensor.gree_salon_humidity -sensor.gree_salon_power -sensor.gree_salon_mode -sensor.gree_salon_fan -sensor.gree_salon_connection -sensor.gree_salon_last_update -sensor.gree_salon_energy -``` - -oraz diagnostykę: - -``` -``` - -``` -sensor.gree_salon_response_time -sensor.gree_salon_packets -sensor.gree_salon_errors -sensor.gree_salon_uptime -``` - ---- - -# Discovery - -To też możemy zrobić porządnie. - -Standardowo GREE wykorzystuje UDP/7000. Discovery może działać przez broadcast, a dla VLAN-ów możemy zrobić dodatkowo aktywne wykrywanie po podsieciach. Istniejące implementacje już stosują unicast UDP/7000 właśnie do urządzeń znajdujących się w innych VLAN-ach. - -W naszym UI: - -``` -``` - -``` -GREE Controller -──────────────────────────────── - -Devices - -✓ GREE Salon - 192.168.10.51 - AA:BB:CC:DD:EE:01 - Online - 22.4°C - -✓ GREE Sypialnia - 192.168.10.52 - AA:BB:CC:DD:EE:02 - Online - 20.8°C - -[ Scan network ] - -Network: -192.168.10.0/24 - -[ Scan ] -``` - -A po znalezieniu: - -``` -``` - -``` -Found GREE device - -Model: GREE ... -MAC: xx:xx:xx:xx:xx:xx -IP: 192.168.10.51 -Protocol: GREE / EWPE -Encryption: AES-128-GCM -Firmware: ... - -[ Add device ] -``` - ---- - -# I tutaj widzę bardzo ważną rzecz - -Nie opierałbym projektu bezpośrednio na obecnym kodzie integracji HA. - -Mamy już kilka projektów, które pokazują różne implementacje protokołu: - -- Home Assistant GreeClimateComponent -- `gree-remote` -- implementacje EWPE -- implementacje Rust -- lokalne serwery GREE - -Nawet istniejący projekt Rust ma już architekturę bardzo zbliżoną do tego, co chcemy osiągnąć: daemon rozmawia z klimatyzatorem przez UDP/7000, a frontend dostaje normalne dane JSON i aktualizacje na żywo. - -**Ale nasz projekt powinien mieć własną warstwę protokołu.** - -Czyli: - -``` -``` - -``` -gree_protocol/ - discovery - encryption - packet - bind - status - command - device_capabilities -``` - -nad tym: - -``` -``` - -``` -gree_core/ - Device - DeviceManager - StateManager - Scheduler - AutomationEngine - HeatingManager -``` - -nad tym: - -``` -``` - -``` -api/ - REST - WebSocket -``` - -oraz: - -``` -``` - -``` -web/ - dashboard - devices - automations - heating - schedules - diagnostics -``` - -i osobno: - -``` -``` - -``` -homeassistant/ - config_flow - climate - sensor - switch - binary_sensor -``` - ---- - -# Najciekawsza część: inteligentne ogrzewanie - -Tutaj nasza aplikacja może być **dużo bardziej użyteczna niż zwykła integracja GREE**. - -Przykład: - -### Strefa: salon - -``` -``` - -``` -Heating zone: Salon - -Target: - 21.5°C - -Comfort: - 21.5°C - -Night: - 19.0°C - -Away: - 17.0°C - -Minimum: - 16.0°C -``` - -Źródło temperatury: - -``` -``` - -``` -○ GREE -● Xiaomi BLE sensor -○ Home Assistant sensor -○ średnia z kilku czujników -``` - -I wtedy kontroler decyduje: - -``` -``` - -``` -22:00 -↓ -Night mode -↓ -target = 19°C - -06:30 -↓ -Morning -↓ -target = 21°C - -08:30 -↓ -Nobody home -↓ -target = 18°C - -16:00 -↓ -Somebody home -↓ -target = 21°C -``` - -Ale możemy zrobić jeszcze lepiej. - ---- - -# Histereza - -Nie chcemy: - -``` -``` - -``` -21.0 → ON -21.1 → OFF -21.0 → ON -21.1 → OFF -``` - -tylko: - -``` -``` - -``` -target = 21°C -hysteresis = 0.4°C - -ON < 20.6°C -OFF > 21.4°C -``` - -A dodatkowo: - -``` -``` - -``` -minimum ON time -minimum OFF time -minimum interval between commands -``` - -żeby nie katować klimatyzatora. - ---- - -# Predykcyjne ogrzewanie - -To jest funkcja, którą naprawdę warto zrobić. - -Jeżeli system zauważy: - -``` -``` - -``` -18:00 19.2°C -18:15 19.7°C -18:30 20.1°C -18:45 20.5°C -19:00 20.8°C -``` - -może policzyć: - -``` -``` - -``` -Heating rate: -+0.53°C / hour -``` - -i wiedzieć, że dojście do 21°C zajmie około: - -``` -``` - -``` -23 min -``` - -Możemy więc ustawić: - -> „O 19:00 chcę mieć 21°C” - -i rozpocząć ogrzewanie odpowiednio wcześniej. - ---- - -# Możemy też sterować wieloma klimatyzatorami - -Przykładowo: - -``` -``` - -``` - Heating Zone - │ - ┌─────────┴─────────┐ - │ │ - GREE Salon GREE Kuchnia - 21°C 21°C - │ │ - └─────────┬─────────┘ - │ - Temperature - 21.2°C -``` - -System może zdecydować: - -``` -``` - -``` -Salon: - HEAT 21°C - -Kuchnia: - OFF -``` - -albo: - -``` -``` - -``` -Salon: - HEAT 20.5°C - -Kuchnia: - HEAT 20.5°C -``` - ---- - -# Automatyzacje - -Zamiast pisać YAML: - -``` -``` - -``` -trigger: -... -condition: -... -action: -... -``` - -robimy prosty GUI: - -``` -``` - -``` -Automation - -WHEN - -[ Temperature ] -[ Salon ] -[ falls below ] -[ 20°C ] - -AND - -[ Time ] -[ 06:00 - 22:00 ] - -THEN - -[ Climate ] -[ Salon ] -[ Heat ] -[ 21°C ] -``` - -Albo: - -``` -``` - -``` -WHEN - Outside temperature < 5°C - -AND - Time = 22:00 - -THEN - Set heating zone "Salon" - Target = 19°C - Fan = Auto -``` - ---- - -# Live state - -Web UI zrobiłbym na WebSocketach/SSE. - -Na dashboardzie: - -``` -``` - -``` -┌──────────────────────────────────────────┐ -│ SALON │ -│ │ -│ 21.3 °C │ -│ │ -│ Target 21.0 °C │ -│ │ -│ ● HEATING │ -│ │ -│ Mode HEAT │ -│ Fan AUTO │ -│ Swing AUTO │ -│ │ -│ Power ███████████░ │ -│ │ -│ [ − ] 21.0°C [ + ] │ -└──────────────────────────────────────────┘ -``` - -I **bez odświeżania strony**: - -``` -``` - -``` -AC → Controller → WebSocket → Browser -``` - ---- - -# Historia - -Możemy przechowywać: - -``` -``` - -``` -temperature -target temperature -mode -fan -power -connection -commands -errors -``` - -i zrobić wykres: - -``` -``` - -``` -22°C ┤ ╭──────── -21°C ┤ ╭──────╯ -20°C ┤ ╭────╯ -19°C ┤───────╯ - └──────────────────────────── - 12 14 16 18 20 22 -``` - -Do tego: - -``` -``` - -``` -Heating time today: 3h 42m -Commands: 27 -Communication errors: 0 -Estimated consumption: ... -``` - ---- - -# Bardzo ważna rzecz — odporność - -To jest właśnie coś, co może być **lepsze od obecnej integracji HA**. - -Nie chcemy: - -``` -``` - -``` -HA - │ - ├── request - │ - ├── timeout - │ - ├── retry - │ - └── error -``` - -tylko: - -``` -``` - -``` - Controller - │ - persistent state - │ - ┌─────────────┴─────────────┐ - │ │ - GREE AC Home Assistant -``` - -Controller cały czas monitoruje urządzenia. - -Jeżeli AC zniknie: - -``` -``` - -``` -ONLINE - ↓ -TIMEOUT - ↓ -RETRY 1 - ↓ -RETRY 2 - ↓ -OFFLINE -``` - -Po powrocie: - -``` -``` - -``` -OFFLINE - ↓ -DISCOVER/RECONNECT - ↓ -GET STATE - ↓ -ONLINE -``` - -HA dostaje już tylko poprawny stan. - ---- - -# I jest jeszcze jedna bardzo istotna kwestia - -**Nowsze firmware GREE faktycznie może być problemem.** - -Znalazłem świeże zgłoszenie z lipca 2026 dotyczące GREE Clivia, gdzie autor raportuje, że na firmware 2.12+ urządzenie pozostaje pingowalne, ale nie odpowiada na UDP/7000, przez co standardowa integracja HA nie może wykonać discovery/bind. - -To oznacza, że zanim zaczniemy pisać GUI, zrobiłbym **reverse engineering konkretnego dongla/klimatyzatora, którego masz**. - -I to jest najlepszy pierwszy etap. - -### Etap 1 — „GREE Protocol Lab” - -Napiszemy mały program: - -``` -``` - -``` -gree-probe -``` - -który: - -``` -``` - -``` -1. nasłuchuje UDP -2. wyszukuje GREE -3. identyfikuje urządzenie -4. wykonuje discovery -5. próbuje bind -6. odczytuje capabilities -7. odczytuje status -8. wykonuje pojedyncze komendy -9. loguje wszystkie pakiety -10. zapisuje PCAP -``` - -Np.: - -``` -``` - -``` -$ gree-probe scan - -Found 2 devices - -192.168.10.51 - MAC: ... - Model: ... - Protocol: GREE - Encryption: AES-GCM - UDP: 7000 - Status: OK - -192.168.10.52 - MAC: ... - Model: ... - Protocol: GREE - Encryption: AES-GCM - UDP: 7000 - Status: OK -``` - -Potem: - -``` -``` - -``` -gree-probe monitor 192.168.10.51 -``` - -i dostajemy: - -``` -``` - -``` -14:32:01 temp=21.4 target=21.0 mode=heat fan=auto -14:32:02 temp=21.4 target=21.0 mode=heat fan=auto -14:32:03 temp=21.5 target=21.0 mode=heat fan=auto -``` - -Dopiero na tym fundamencie budowałbym resztę. - -**I zdecydowanie nie robiłbym tego jako kolejnego prostego custom componentu HA.** Zrobiłbym własny daemon/controller + oficjalny custom integration HA. Wtedy możesz mieć jednocześnie **pełną kompatybilność encji z HA, własny panel WWW, własny scheduler, inteligentne ogrzewanie, historię i automatyzacje**, a protokół GREE jest całkowicie odseparowany od Home Assistanta. - -Jeżeli chcesz, możemy teraz przejść do **konkretnej architektury projektu i zacząć od reverse-engineeringu protokołu GREE — pliki, moduły, Python/Rust, API, baza danych i później gotowy custom integration dla HA**. - -Plan jest na to absyntów było w rust z interfejsem i będzie to na osobnym hoście/ kontenerze lxc lub w integracji homeassistant i jakoś trzeba pogodzić przekazywanie encji, generalnie osobny kontener i środowisko to must have - -Tak — w takim przypadku **zdecydowanie wybrałbym osobny kontener z usługą w Rust**, a integrację Home Assistant potraktowałbym wyłącznie jako cienkiego klienta. To daje nam izolację, własny lifecycle i możliwość rozwijania całego systemu niezależnie od HA. - -Najważniejsze jest tylko dobre rozwiązanie kwestii encji. - -## Docelowa architektura - -``` -``` - -``` - ┌─────────────────────────────┐ - │ HOME ASSISTANT │ - │ │ - │ climate.gree_salon │ - │ sensor.gree_salon_temp │ - │ sensor.gree_salon_humidity │ - │ switch.gree_salon_turbo │ - │ ... │ - └──────────────┬──────────────┘ - │ - Custom Integration - │ - WebSocket + REST - │ - ┌──────────────▼──────────────┐ - │ GREE CONTROLLER │ - │ │ - │ Rust │ - │ │ - │ ┌───────────────────────┐ │ - │ │ GREE Protocol │ │ - │ │ UDP / AES / discovery │ │ - │ └───────────────────────┘ │ - │ │ - │ ┌───────────────────────┐ │ - │ │ Device Manager │ │ - │ └───────────────────────┘ │ - │ │ - │ ┌───────────────────────┐ │ - │ │ Automation Engine │ │ - │ └───────────────────────┘ │ - │ │ - │ ┌───────────────────────┐ │ - │ │ Heating Manager │ │ - │ └───────────────────────┘ │ - │ │ - │ ┌───────────────────────┐ │ - │ │ REST API │ │ - │ │ WebSocket │ │ - │ └───────────────────────┘ │ - │ │ - │ ┌───────────────────────┐ │ - │ │ Web UI │ │ - │ └───────────────────────┘ │ - └──────────────┬──────────────┘ - │ - LAN / VLAN - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - GREE #1 GREE #2 GREE #3 -``` - -### Kontener - -Ja bym zrobił: - -``` -``` - -``` -LXC - └── gree-controller - ├── gree-controller - ├── gree.db - └── config/ -``` - -albo Docker/Podman, jeżeli później będziesz chciał łatwo przenosić usługę. - -**Rust + jeden binarny executable** jest tutaj bardzo dobrym rozwiązaniem. - ---- - -# Encje HA - -Nie przekazywałbym encji z kontenera bezpośrednio. - -To znaczy controller **nie powinien próbować udawać Home Assistanta**. - -Zamiast tego: - -``` -``` - -``` -GREE Controller - │ - │ API - ▼ -HA Custom Integration - │ - ▼ -HA Entity Registry - │ - ├── climate - ├── sensor - ├── switch - ├── binary_sensor - └── number -``` - -Czyli: - -**Rust zarządza urządzeniem, HA zarządza encją.** - -To jest bardzo ważne rozdzielenie odpowiedzialności. - ---- - -# Jak będzie wyglądała komunikacja - -Najlepiej zrobić **WebSocket jako kanał realtime**, a REST jako konfigurację/command API. - -### REST - -``` -``` - -``` -GET /api/v1/devices - -GET /api/v1/devices/{id} - -GET /api/v1/devices/{id}/state - -POST /api/v1/devices/{id}/commands - -GET /api/v1/zones - -GET /api/v1/automations -POST /api/v1/automations - -GET /api/v1/history -``` - -### WebSocket - -``` -``` - -``` -WS /api/v1/events -``` - -Controller wysyła: - -``` -``` - -``` -{ - "event": "device_state", - "device_id": "gree-salon", - "state": { - "power": true, - "mode": "heat", - "temperature": 21.4, - "target_temperature": 21.0, - "fan": "auto" - } -} -``` - -HA dostaje to natychmiast. - ---- - -# Co z komendami? - -HA: - -``` -``` - -``` -climate.set_temperature -``` - -↓ - -Custom integration: - -``` -``` - -``` -POST /api/v1/devices/gree-salon/commands -``` - -↓ - -Rust: - -``` -``` - -``` -Command::SetTemperature(21.0) -``` - -↓ - -GREE UDP - -↓ - -odpowiedź - -↓ - -Rust aktualizuje stan - -↓ - -WebSocket - -↓ - -HA - -I wtedy HA nie musi samodzielnie rozmawiać z GREE. - ---- - -# Ale zrobiłbym jeszcze jedną rzecz - -**Stan powinien być przechowywany w controllerze.** - -Czyli jeśli HA się zrestartuje: - -``` -``` - -``` -HA - ↓ -restart - ↓ -integration connects - ↓ -GET /api/v1/devices - ↓ -GET current state -``` - -i natychmiast odtwarza encje. - -A nie: - -``` -``` - -``` -HA restart - ↓ -czekamy aż GREE odpowie - ↓ -discovery - ↓ -bind - ↓ -state -``` - -Controller już wie: - -``` -``` - -``` -GREE Salon - last_state - last_seen - capabilities - configuration -``` - ---- - -# Discovery HA - -Custom integration może działać bardzo elegancko. - -Po instalacji: - -``` -``` - -``` -Settings - → Devices & services - → Add integration - → GREE Controller -``` - -Podajesz: - -``` -``` - -``` -Host: -192.168.1.20 - -Port: -8765 -``` - -Integration robi: - -``` -``` - -``` -GET /api/v1/info -``` - -i: - -``` -``` - -``` -GET /api/v1/devices -``` - -Dostaje: - -``` -``` - -``` -{ - "devices": [ - { - "id": "gree-salon", - "name": "Salon", - "type": "air_conditioner", - "capabilities": [ - "heat", - "cool", - "dry", - "fan", - "swing", - "turbo" - ] - } - ] -} -``` - -I tworzy encje. - ---- - -# Co daje nam osobny Rust? - -Ogromnie dużo. - -### 1. HA nie musi znać protokołu GREE - -Jeżeli GREE zmieni: - -``` -``` - -``` -AES -UDP -bind -discovery -packet format -``` - -aktualizujemy controller. - -HA integration pozostaje praktycznie bez zmian. - ---- - -### 2. Możemy mieć własny Web UI - -I to będzie **pełnoprawny panel**, nie dodatek do HA. - -Np.: - -``` -``` - -``` -Dashboard - -┌───────────────────────────────────────────────┐ -│ GREE CONTROLLER │ -│ │ -│ 3 devices 3 online 0 errors │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ SALON │ │ SYPIALNIA │ │ -│ │ │ │ │ │ -│ │ 21.4°C │ │ 20.1°C │ │ -│ │ → 21.0°C │ │ → 19.0°C │ │ -│ │ │ │ │ │ -│ │ HEAT │ │ HEAT │ │ -│ └──────────────┘ └──────────────┘ │ -└───────────────────────────────────────────────┘ -``` - ---- - -# 3. Automatyzacje nie muszą zależeć od HA - -I to jest moim zdaniem **najciekawsza rzecz**. - -Możemy zrobić: - -``` -``` - -``` -GREE Controller - │ - ├── automation engine - │ - ├── heating engine - │ - ├── scheduler - │ - └── HA integration -``` - -Czyli np. ogrzewanie działa nawet kiedy HA jest wyłączony. - ---- - -# Ale wtedy potrzebujemy integracji z HA także w drugą stronę - -To znaczy Rust może potrzebować informacji z HA: - -``` -``` - -``` -temperatura z czujnika -obecność -otwarte okno -temperatura zewnętrzna -tryb domu -``` - -Nie robiłbym jednak bezpośredniego dostępu Rust → HA API jako podstawowej architektury. - -Lepiej: - -``` -``` - -``` -HA - │ - │ WebSocket/API - ▼ -GREE Integration - │ - ▼ -Rust Controller -``` - -Integracja może opcjonalnie przekazywać do controllera wybrane encje HA. - -Na przykład: - -``` -``` - -``` -External sensors - -Outdoor temperature - → sensor.outdoor_temperature - -Living room temperature - → sensor.living_room_temperature - -Presence - → binary_sensor.home_occupied -``` - -Controller może wtedy używać ich w automatyzacjach. - ---- - -# Strefy ogrzewania - -Wtedy możemy zrobić coś znacznie bardziej zaawansowanego. - -``` -``` - -``` -Heating -│ -├── Zone: Salon -│ ├── GREE Salon -│ ├── temperature sensor -│ └── schedule -│ -├── Zone: Sypialnia -│ ├── GREE Sypialnia -│ └── temperature sensor -│ -└── Zone: Biuro - ├── GREE Biuro - └── temperature sensor -``` - -I każda strefa ma: - -``` -``` - -``` -Comfort: 21.5°C -Night: 19°C -Away: 17°C -Frost protect: 10°C - -Schedule: -06:00 → Comfort -08:30 → Away -16:00 → Comfort -23:00 → Night -``` - ---- - -# Technologia - -Ja bym poszedł w: - -``` -``` - -``` -Rust -├── tokio -├── axum -├── serde -├── serde_json -├── sqlx -├── SQLite -├── tracing -├── tower -└── tokio-tungstenite / axum WebSocket -``` - -Frontend: - -``` -``` - -``` -React / TypeScript -``` - -albo, jeśli chcemy maksymalnie ograniczyć zasoby: - -``` -``` - -``` -Leptos -``` - -Ale tutaj **nie komplikowałbym pierwszej wersji**. Rust backend + lekki frontend będzie rozsądniejszy. - ---- - -# SQLite - -Na początek: - -``` -``` - -``` -gree.db -``` - -z tabelami mniej więcej: - -``` -``` - -``` -devices -device_capabilities -device_state -device_history - -zones -zone_devices -zone_sensors - -automations -automation_conditions -automation_actions - -schedules - -settings -``` - -Bez PostgreSQL. - -To będzie jedna usługa na jednym LXC, więc SQLite jest idealny. - ---- - -# I jeszcze jedna decyzja architektoniczna - -Nie robiłbym: - -``` -``` - -``` -Rust - └── web server - └── GREE protocol -``` - -jako jednego wielkiego modułu. - -Tylko: - -``` -``` - -``` -src/ -├── main.rs -│ -├── api/ -│ ├── rest.rs -│ ├── websocket.rs -│ └── auth.rs -│ -├── gree/ -│ ├── protocol.rs -│ ├── crypto.rs -│ ├── discovery.rs -│ ├── device.rs -│ ├── commands.rs -│ └── state.rs -│ -├── devices/ -│ ├── manager.rs -│ ├── capabilities.rs -│ └── registry.rs -│ -├── automation/ -│ ├── engine.rs -│ ├── conditions.rs -│ └── actions.rs -│ -├── heating/ -│ ├── controller.rs -│ ├── zones.rs -│ ├── hysteresis.rs -│ └── prediction.rs -│ -├── scheduler/ -│ -├── database/ -│ -└── events/ -``` - -To pozwoli nam później rozwijać projekt bez robienia spaghetti. - ---- - -## Moim zdaniem docelowo powinno to wyglądać tak - -``` -``` - -``` - ┌────────────────────────────┐ - │ HOME ASSISTANT │ - │ │ - │ Native HA entities │ - │ │ - │ GREE custom integration │ - └─────────────┬──────────────┘ - │ - REST + WebSocket - │ - ┌─────────────▼──────────────┐ - │ GREE CONTROLLER │ - │ │ - │ RUST │ - │ │ - │ ┌────────────────────────┐ │ - │ │ GREE protocol │ │ - │ ├────────────────────────┤ │ - │ │ Device manager │ │ - │ ├────────────────────────┤ │ - │ │ State engine │ │ - │ ├────────────────────────┤ │ - │ │ Automation engine │ │ - │ ├────────────────────────┤ │ - │ │ Heating engine │ │ - │ ├────────────────────────┤ │ - │ │ Scheduler │ │ - │ ├────────────────────────┤ │ - │ │ SQLite │ │ - │ └────────────────────────┘ │ - │ │ - │ Web UI │ - └─────────────┬──────────────┘ - │ - UDP / 7000 - │ - ┌─────────────┼──────────────┐ - ▼ ▼ ▼ - GREE GREE GREE -``` - -**To jest architektura, którą bym przyjął.** Kontener/LXC jest niezależnym, autonomicznym serwisem, a HA dostaje normalne, natywne encje przez custom integration. Dzięki temu nawet całkowite wyłączenie HA nie zabija automatyki ogrzewania. - -Następny krok zrobiłbym już praktycznie: **ustalić dokładnie protokół GREE/EWPE dla obecnych dongli, wybrać Rust crate/implementację jako punkt startowy i zaprojektować v1 API + model encji HA**, zanim zaczniemy pisać właściwy kod. - -I jako dodatek do odczytu temperatur i lepsze odczyty jako opcja dodatkowa odczyt z api homeassistant zewnętrznego czujnika temperatury w pokoju z api homeassistant ale nie jako główne zrodlo informacji tylko jako opcja dodatkowa żeby wspomóc algorytm, zrób takamspecyfikacje uniezależnij to Dodatkowo interfejs musi być napełni kompatybilny z komórek bo to będzie główne miejsce sterowania, zastanawiam się na tailwind css z trybem ciemnym / jasnym Zrób takamspecyfikaje z tym co proponowałeś - -Tak. W tej wersji zrobiłbym z tego **całkowicie niezależny kontroler GREE**, a Home Assistant byłby tylko jednym z możliwych źródeł danych i odbiorcą encji. Szczególnie ważne jest rozdzielenie **temperatury zmierzonej przez klimatyzator**, **temperatury z zewnętrznego sensora HA** oraz **temperatury używanej przez algorytm sterowania**. - -Poniżej specyfikacja, którą można potraktować jako bazę projektu. - -# Specyfikacja GREE Controller - -## 1. Cel projektu - -Samodzielna aplikacja działająca w osobnym środowisku: - -- Rust -- LXC / kontener -- własny backend -- własna baza danych -- własny interfejs WWW -- komunikacja lokalna z klimatyzatorami GREE -- REST API -- WebSocket -- opcjonalna integracja z Home Assistant -- natywne encje Home Assistant -- własny silnik automatyzacji -- własny silnik sterowania ogrzewaniem/chłodzeniem -- obsługa zewnętrznych czujników temperatury - -**Home Assistant nie jest wymagany do działania podstawowych funkcji systemu.** - ---- - -# 2. Zasada nadrzędna - -Architektura ma być **HA-independent**. - -``` -``` - -``` - ┌──────────────────────┐ - │ GREE Controller │ - │ │ - │ Rust │ - │ │ - │ GREE protocol │ - │ State engine │ - │ Heating engine │ - │ Automation engine │ - │ Scheduler │ - │ Web UI │ - │ Database │ - └──────────┬───────────┘ - │ - ┌─────────────┴──────────────┐ - │ │ - GREE AC Home Assistant - │ │ - │ Custom Integration - │ │ - │ Entities / sensors -``` - -HA jest **integracją z kontrolerem**, a nie jego fundamentem. - ---- - -# 3. Źródła temperatury - -To będzie jeden z najważniejszych elementów projektu. - -Każda strefa może mieć kilka źródeł temperatury. - -### Źródło 1 — temperatura GREE - -Podstawowe: - -``` -``` - -``` -GREE internal temperature -``` - -np. - -``` -``` - -``` -21.7°C -``` - -To jest temperatura raportowana przez klimatyzator. - ---- - -### Źródło 2 — zewnętrzny sensor - -Opcjonalnie: - -``` -``` - -``` -Home Assistant - ↓ -REST API / WebSocket - ↓ -GREE Controller - ↓ -external temperature -``` - -Przykład: - -``` -``` - -``` -sensor.salon_temperature -``` - -Controller może cyklicznie pobierać: - -``` -``` - -``` -21.2°C -``` - -ale **nie traktuje tego automatycznie jako zamiennika temperatury GREE**. - ---- - -### Źródło 3 — temperatura obliczona - -Najważniejsze: - -``` -``` - -``` -CONTROL TEMPERATURE -``` - -czyli temperatura, którą faktycznie wykorzystuje algorytm. - -Przykładowo: - -``` -``` - -``` -GREE: -21.7°C - -External: -21.2°C - -Control: -21.4°C -``` - -Algorytm może korzystać z: - -``` -``` - -``` -control_temperature = - weighted_average( - gree_temperature, - external_temperature - ) -``` - -albo: - -``` -``` - -``` -control_temperature = - external_temperature -``` - -jeżeli użytkownik jawnie tak skonfiguruje. - ---- - -# 4. Konfiguracja źródła temperatury - -W UI: - -``` -``` - -``` -Temperature source - -○ GREE internal sensor - -○ External Home Assistant sensor - -● Combined - - GREE weight: 40% - External weight: 60% - -External sensor: - sensor.salon_temperature -``` - -Dodatkowo: - -``` -``` - -``` -Maximum allowed difference: -3.0°C -``` - -Jeżeli: - -``` -``` - -``` -GREE = 21°C -External = 26°C -``` - -system nie powinien bezwarunkowo przyjąć 26°C. - -Generuje: - -``` -``` - -``` -Temperature sensor discrepancy -``` - -i może automatycznie przejść na GREE. - ---- - -# 5. Confidence / jakość pomiaru - -Każdy pomiar powinien mieć metadane: - -``` -``` - -``` -temperature -timestamp -source -age -quality -``` - -Przykład: - -``` -``` - -``` -External sensor - -Temperature: 21.3°C -Age: 4 sec -Quality: 100% -``` - -Jeżeli HA przestanie odpowiadać: - -``` -``` - -``` -Age: 83 sec -Quality: 20% -``` - -Po przekroczeniu timeoutu: - -``` -``` - -``` -External sensor: -UNAVAILABLE -``` - -i system automatycznie korzysta z GREE. - -To jest bardzo ważne dla niezależności. - ---- - -# 6. Algorytm sterowania - -Nie robimy prostego: - -``` -``` - -``` -if temp < target: - heat() -``` - -tylko warstwę kontrolną. - -``` -``` - -``` -Sensors - ↓ -Sensor validation - ↓ -Temperature estimator - ↓ -Target temperature - ↓ -Heating/Cooling controller - ↓ -Safety limits - ↓ -GREE command -``` - ---- - -# 7. Target temperature - -Temperatura docelowa może wynikać z: - -``` -``` - -``` -Manual -Schedule -Night -Away -Presence -External temperature -Automation -Heating zone -``` - -Przykład: - -``` -``` - -``` -23:00 -Night profile -↓ -19°C - -06:00 -Morning -↓ -21°C - -08:30 -Away -↓ -17°C - -16:00 -Home -↓ -21°C -``` - ---- - -# 8. Histereza - -Konfigurowalna: - -``` -``` - -``` -Target: 21°C -Hysteresis: 0.4°C -``` - -czyli: - -``` -``` - -``` -< 20.6°C → HEAT - -20.6–21.4°C → maintain - -> 21.4°C → STOP -``` - -Dodatkowo: - -``` -``` - -``` -Minimum ON time -Minimum OFF time -Minimum command interval -Maximum commands/hour -``` - ---- - -# 9. Inteligentne ogrzewanie - -System powinien opcjonalnie analizować: - -``` -``` - -``` -temperature history -target history -heating state -outside temperature -time -heating rate -``` - -i obliczać: - -``` -``` - -``` -temperature change/hour -``` - -Przykład: - -``` -``` - -``` -19:00 19.4°C -19:15 19.8°C -19:30 20.2°C -19:45 20.6°C -``` - -System: - -``` -``` - -``` -Heating rate ≈ 1.6°C/hour -``` - -Może więc przewidzieć: - -``` -``` - -``` -Target 21°C -ETA ≈ 15 min -``` - -W kolejnej wersji można dodać predykcję rozpoczęcia ogrzewania. - ---- - -# 10. Strefy - -Klimatyzator nie musi być bezpośrednio jednostką sterowania. - -Tworzymy: - -``` -``` - -``` -Zone -``` - -np. - -``` -``` - -``` -Salon -``` - -która posiada: - -``` -``` - -``` -Devices: - GREE Salon - -Temperature sources: - GREE - sensor.salon_temperature - -Target: - 21°C - -Schedule: - Salon schedule - -Control: - Intelligent heating -``` - -Dzięki temu później można mieć: - -``` -``` - -``` -Salon - ├── GREE - ├── Xiaomi temperature sensor - └── window sensor - -Sypialnia - ├── GREE - └── temperature sensor -``` - ---- - -# 11. Home Assistant jako opcjonalne źródło danych - -Controller posiada moduł: - -``` -``` - -``` -home_assistant/ -``` - -który jest **opcjonalny**. - -Konfiguracja: - -``` -``` - -``` -Home Assistant integration - -Enabled: YES - -URL: -http://homeassistant:8123 - -Token: -************ - -Poll interval: -10 sec -``` - -Możemy pobierać: - -``` -``` - -``` -sensor.* -binary_sensor.* -input_boolean.* -input_number.* -``` - -ale użytkownik wskazuje konkretne encje. - -Np.: - -``` -``` - -``` -External temperature: - -HA entity: -sensor.salon_temperature -``` - -Nie robimy automatycznego pobierania całego HA. - ---- - -# 12. HA nie może być wymaganiem - -Jeżeli: - -``` -``` - -``` -HA DOWN -``` - -system nadal: - -- steruje GREE -- wykonuje harmonogram -- wykonuje automatyzacje lokalne -- używa temperatury GREE -- działa przez Web UI -- przechowuje historię - -Jedynie: - -``` -``` - -``` -External HA sensors -``` - -stają się niedostępne. - ---- - -# 13. Integracja Home Assistant - -Po stronie HA: - -``` -``` - -``` -GREE Controller - ↓ -Custom Integration - ↓ -Device - ↓ -Entities -``` - -Dla klimatyzatora: - -``` -``` - -``` -climate.gree_salon -``` - -oraz np.: - -``` -``` - -``` -sensor.gree_salon_temperature -sensor.gree_salon_target_temperature -sensor.gree_salon_power -sensor.gree_salon_connection -sensor.gree_salon_fan -``` - ---- - -# 14. Encja Climate - -Powinna być maksymalnie kompatybilna z natywnym HA. - -Obsługa: - -``` -``` - -``` -OFF -HEAT -COOL -AUTO -DRY -FAN_ONLY -``` - -oraz: - -``` -``` - -``` -target temperature -fan mode -preset mode -swing -``` - ---- - -# 15. Dodatkowe encje - -W zależności od capabilities urządzenia: - -``` -``` - -``` -switch.gree_salon_turbo -switch.gree_salon_quiet -switch.gree_salon_sleep -switch.gree_salon_light -switch.gree_salon_xfan -``` - -oraz: - -``` -``` - -``` -sensor.gree_salon_temperature -sensor.gree_salon_humidity -sensor.gree_salon_power -sensor.gree_salon_signal -``` - -Nie tworzymy encji dla funkcji, których konkretna jednostka nie obsługuje. - ---- - -# 16. Web UI — bardzo ważne - -Ponieważ telefon będzie głównym sposobem obsługi, projektujemy UI **mobile-first**, a nie desktop-first. - -Technologia: - -**Tailwind CSS** - -z: - -``` -``` - -``` -Dark mode -Light mode -System mode -``` - -czyli: - -``` -``` - -``` -Appearance - -● System -○ Dark -○ Light -``` - ---- - -# 17. Mobile UI - -Na telefonie dashboard: - -``` -``` - -``` -┌────────────────────────┐ -│ GREE Controller ☰ │ -├────────────────────────┤ -│ │ -│ SALON │ -│ │ -│ 21.4°C │ -│ │ -│ Target 21°C │ -│ │ -│ 🔥 HEATING │ -│ │ -│ − 21°C + │ -│ │ -│ HEAT AUTO │ -│ │ -│ ────────────────────── │ -│ │ -│ Fan Auto │ -│ Swing Auto │ -│ │ -│ Turbo ○ │ -│ Quiet ● │ -│ Sleep ○ │ -│ │ -└────────────────────────┘ -``` - -Bez mikroskopijnych przycisków. - -Minimalny rozmiar interaktywnych elementów powinien być około: - -``` -``` - -``` -44 × 44 px -``` - ---- - -# 18. Mobile navigation - -Dolny pasek: - -``` -``` - -``` -┌────────────────────────┐ -│ │ -│ CONTENT │ -│ │ -├────────────────────────┤ -│ 🏠 🌡 ⚙ │ -│ Home Zones More │ -└────────────────────────┘ -``` - -Na desktopie może przełączać się w sidebar. - ---- - -# 19. Dashboard - -Główne widoki: - -``` -``` - -``` -Dashboard -Devices -Zones -Heating -Automations -Schedules -History -Diagnostics -Settings -``` - ---- - -# 20. Automatyzacje - -GUI: - -``` -``` - -``` -WHEN - -Temperature -[ Salon ] - -[ < ] - -[ 20°C ] - -AND - -Time -[ 06:00 - 22:00 ] - -THEN - -Climate -[ Salon ] - -Mode: -[ HEAT ] - -Target: -[ 21°C ] -``` - -Ale silnik jest w Rust. - -HA nie jest potrzebny. - ---- - -# 21. WebSocket - -Frontend nie powinien wykonywać ciągłego: - -``` -``` - -``` -GET /state -GET /state -GET /state -``` - -Tylko: - -``` -``` - -``` -Browser - │ - │ WebSocket - ▼ -Rust - │ - ▼ -Event stream -``` - -Przykładowo: - -``` -``` - -``` -device_state_changed -temperature_changed -device_online -device_offline -automation_triggered -heating_state_changed -``` - -Dzięki temu UI będzie faktycznie „live”. - ---- - -# 22. API - -REST: - -``` -``` - -``` -/api/v1/system -/api/v1/devices -/api/v1/devices/{id} -/api/v1/devices/{id}/state -/api/v1/devices/{id}/commands - -/api/v1/zones -/api/v1/zones/{id} - -/api/v1/automations -/api/v1/schedules - -/api/v1/sensors -/api/v1/history - -/api/v1/ha -``` - -WebSocket: - -``` -``` - -``` -/api/v1/ws -``` - ---- - -# 23. Authentication - -Ponieważ będzie to osobny host, od początku robimy: - -``` -``` - -``` -API authentication -``` - -np.: - -``` -``` - -``` -Bearer token -``` - -oraz osobne konto dla Web UI. - -W przyszłości: - -``` -``` - -``` -OIDC -``` - -ale nie jest potrzebne w v1. - ---- - -# 24. GREE Protocol Layer - -Cały protokół izolujemy: - -``` -``` - -``` -gree/ -├── discovery.rs -├── protocol.rs -├── crypto.rs -├── packet.rs -├── bind.rs -├── commands.rs -├── state.rs -└── capabilities.rs -``` - -Dzięki temu reszta programu **nie wie, że pod spodem jest GREE**. - -To pozwoli kiedyś potencjalnie dodać: - -``` -``` - -``` -gree/ -haier/ -midea/ -``` - -bez przebudowy całego systemu. - ---- - -# 25. Model urządzenia - -``` -``` - -``` -Device -├── id -├── name -├── ip -├── mac -├── model -├── firmware -├── protocol -├── capabilities -├── state -├── last_seen -└── availability -``` - ---- - -# 26. Stan urządzenia - -``` -``` - -``` -DeviceState -├── power -├── mode -├── target_temperature -├── room_temperature -├── humidity -├── fan_mode -├── swing_vertical -├── swing_horizontal -├── preset -├── turbo -├── quiet -└── ... -``` - -Nie zakładamy, że każde GREE ma wszystkie pola. - ---- - -# 27. Capability discovery - -Po wykryciu urządzenia controller określa: - -``` -``` - -``` -supports_heat -supports_cool -supports_dry -supports_fan -supports_turbo -supports_quiet -supports_swing -supports_humidity -... -``` - -Frontend oraz HA integration korzystają z capabilities. - ---- - -# 28. Baza - -Na początek: - -**SQLite** - -``` -``` - -``` -devices -device_capabilities -device_state -temperature_history -events - -zones -zone_devices -zone_sensors - -automations -automation_conditions -automation_actions - -schedules - -ha_connections -ha_entities - -settings -``` - -Bez zewnętrznego PostgreSQL. - ---- - -# 29. Historia - -Zapisywane: - -``` -``` - -``` -temperature -target -mode -power -fan -heating state -source temperature -``` - -Dzięki temu UI może pokazać: - -``` -``` - -``` -Temperature - -22°C ┤ ╭────── -21°C ┤ ──────╯ -20°C ┤ - └──────────────── -``` - -oraz: - -``` -``` - -``` -GREE: 21.7°C -External: 21.2°C -Control: 21.4°C -``` - -To ostatnie jest szczególnie istotne diagnostycznie. - ---- - -# 30. Diagnostics - -Każdy GREE: - -``` -``` - -``` -Status: ONLINE - -IP: -192.168.x.x - -Last seen: -1.2 sec ago - -Latency: -12 ms - -Packets: -12342 - -Errors: -0 - -Last error: -None -``` - -Możliwość: - -``` -``` - -``` -[ Ping ] -[ Refresh state ] -[ Rediscover ] -[ Rebind ] -``` - ---- - -# 31. Kontener - -Docelowo: - -``` -``` - -``` -LXC -└── Debian - └── gree-controller - ├── /usr/bin/gree-controller - ├── /etc/gree-controller/ - └── /var/lib/gree-controller/ - └── gree.db -``` - -Service: - -``` -``` - -``` -systemd -``` - -czyli: - -``` -``` - -``` -gree-controller.service -``` - -Nie potrzebujemy Dockera. - ---- - -# 32. Aktualizacja - -Rust daje nam prosty model: - -``` -``` - -``` -binary -+ -database migrations -``` - -Możemy później zrobić: - -``` -``` - -``` -v0.1 -v0.2 -v0.3 -... -``` - -z migracjami SQLite. - ---- - -# 33. Struktura projektu - -Docelowo: - -``` -``` - -``` -gree-controller/ -│ -├── Cargo.toml -│ -├── crates/ -│ ├── gree-protocol/ -│ ├── gree-core/ -│ ├── gree-api/ -│ └── gree-ha/ -│ -├── backend/ -│ └── src/ -│ -├── frontend/ -│ ├── src/ -│ ├── public/ -│ └── ... -│ -├── ha-integration/ -│ └── custom_components/ -│ └── gree_controller/ -│ -├── migrations/ -│ -├── packaging/ -│ ├── systemd/ -│ └── debian/ -│ -└── README.md -``` - -Przy większym projekcie podział na Cargo workspace będzie bardzo wygodny. - ---- - -# 34. Najważniejsza zasada dotycząca HA - -**Nie kopiujemy logiki między Rust i HA.** - -Nie: - -``` -``` - -``` -HA heating logic -+ -Rust heating logic -``` - -Tylko: - -``` -``` - -``` - Rust Controller - │ - ┌─────────────┴──────────────┐ - │ │ - heating engine automation engine - │ │ - └─────────────┬──────────────┘ - │ - Controller API - │ - Home Assistant - │ - Entities -``` - -HA pokazuje i steruje. - -**Rust podejmuje decyzje.** - ---- - -# 35. Kolejność implementacji - -Nie zaczynałbym od GUI. - -### Faza 1 — GREE protocol - -``` -``` - -``` -[ ] discovery -[ ] bind -[ ] encryption -[ ] status -[ ] commands -[ ] capabilities -[ ] reconnect -``` - -### Faza 2 — Core - -``` -``` - -``` -[ ] Device Manager -[ ] State Manager -[ ] event system -[ ] SQLite -[ ] configuration -``` - -### Faza 3 — API - -``` -``` - -``` -[ ] REST -[ ] WebSocket -[ ] authentication -``` - -### Faza 4 — Web UI - -``` -``` - -``` -[ ] Tailwind -[ ] dark/light -[ ] mobile-first -[ ] dashboard -[ ] devices -[ ] zones -[ ] history -[ ] diagnostics -``` - -### Faza 5 — Heating Engine - -``` -``` - -``` -[ ] target -[ ] schedules -[ ] hysteresis -[ ] minimum run time -[ ] sensor weighting -[ ] sensor fallback -[ ] heating zones -[ ] prediction -``` - -### Faza 6 — Automations - -``` -``` - -``` -[ ] triggers -[ ] conditions -[ ] actions -[ ] scheduler -[ ] GUI builder -``` - -### Faza 7 — Home Assistant - -``` -``` - -``` -[ ] config flow -[ ] device discovery -[ ] climate -[ ] sensors -[ ] switches -[ ] diagnostics -[ ] external HA sensors -``` - ---- - -## Jedna rzecz, którą zmieniłbym względem pierwotnego pomysłu - -Nie nazywałbym tego nawet wewnętrznie „integracją GREE do Home Assistant”. - -To powinien być **samodzielny GREE Controller**, a Home Assistant będzie jednym z klientów. - -Wtedy mamy: - -``` -``` - -``` - GREE Controller - / | \ - / | \ - ▼ ▼ ▼ - Web UI HA REST API - │ - HA entities -``` - -I dokładnie spełniamy Twój warunek: - -**osobne środowisko jest obowiązkowe**, HA nie jest wymagane do działania, zewnętrzny czujnik HA jest tylko **opcjonalnym źródłem wspomagającym algorytm**, a użytkownik może praktycznie całe sterowanie wykonywać z telefonu przez responsywny UI. - -**Tailwind + dark/light + mobile-first** jest tutaj bardzo dobrym wyborem. Zrobiłbym nawet projekt UI od początku z założeniem „telefon jako primary, desktop jako secondary”, zamiast później próbować dopasowywać desktopowy panel do telefonu. \ No newline at end of file diff --git a/home-assistant/custom_components/gree_controller/__pycache__/__init__.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 679355c..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/__pycache__/api.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/api.cpython-313.pyc deleted file mode 100644 index f90a2dd..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/api.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/__pycache__/climate.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/climate.cpython-313.pyc deleted file mode 100644 index cff36f2..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/climate.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/__pycache__/config_flow.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/config_flow.cpython-313.pyc deleted file mode 100644 index 08ffd28..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/config_flow.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/__pycache__/const.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/const.cpython-313.pyc deleted file mode 100644 index 4c64ebf..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/const.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/__pycache__/coordinator.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/coordinator.cpython-313.pyc deleted file mode 100644 index a4267f5..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/coordinator.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/__pycache__/entity_map.cpython-313.pyc b/home-assistant/custom_components/gree_controller/__pycache__/entity_map.cpython-313.pyc deleted file mode 100644 index 66d7f54..0000000 Binary files a/home-assistant/custom_components/gree_controller/__pycache__/entity_map.cpython-313.pyc and /dev/null differ diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 63121b5..fb8e886 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.4.1", + "version": "0.4.2", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/install-lxc.sh b/install-lxc.sh deleted file mode 100755 index b69876c..0000000 --- a/install-lxc.sh +++ /dev/null @@ -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 <` — configures a dedicated GREE NIC, automatic subnet broadcast, disables the simulator, and restarts the service. - `network-debug.sh [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. diff --git a/scripts/__pycache__/generate_ha_migration.cpython-313.pyc b/scripts/__pycache__/generate_ha_migration.cpython-313.pyc deleted file mode 100644 index 3c4dc67..0000000 Binary files a/scripts/__pycache__/generate_ha_migration.cpython-313.pyc and /dev/null differ diff --git a/scripts/build-web.sh b/scripts/build-web.sh deleted file mode 100755 index dcd64be..0000000 --- a/scripts/build-web.sh +++ /dev/null @@ -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" diff --git a/scripts/common.sh b/scripts/common.sh index 12b613b..0d2d2cb 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -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 diff --git a/scripts/dev.sh b/scripts/dev.sh index 9166490..5c25b50 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index ac11681..55c4af3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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)" diff --git a/scripts/smoke.sh b/scripts/smoke.sh index add926f..6f33b19 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -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" diff --git a/scripts/update.sh b/scripts/update.sh index 5fcae3e..d99cd29 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -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}" diff --git a/src/api.rs b/src/api.rs index bde31a3..b69531b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, Query(query): Query, hours: Option, limit: Option } +async fn history(State(state): State, Query(query): Query) -> Result, 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 } async fn events(State(state): State, Query(query): Query) -> Result, AppError> { diff --git a/src/db.rs b/src/db.rs index a2efad0..378b73a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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 { 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 { + 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, limit: u32) -> Result> { + 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, bucket_seconds: i64, limit: u32) -> Result> { + 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 { + let timestamp: String = row.get(3)?; + Ok(ZoneReading { + id: row.get(0)?, + zone_id: row.get(1)?, + device_id: row.get(2)?, + timestamp: DateTime::parse_from_rfc3339(×tamp) + .map(|v| v.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + gree_temperature: row.get(4)?, + external_temperature: row.get(5)?, + control_temperature: row.get(6)?, + target_temperature: row.get(7)?, + device_setpoint: row.get(8)?, + outdoor_temperature: row.get(9)?, + power: row.get::<_, i64>(10)? != 0, + mode: row.get(11)?, + fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8, + demand: row.get::<_, i64>(13)? != 0, + control_source: row.get(14)?, + active_preset: row.get(15)?, + }) } pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result { diff --git a/src/engine.rs b/src/engine.rs index 8f3a61d..31a0497 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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, 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, external_temperature: Option) -> (Option, String, bool) { match zone.sensor_source.as_str() { "home_assistant" => match (external_temperature, device_temperature) { diff --git a/src/models.rs b/src/models.rs index 0493d2b..6bde20d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + pub gree_temperature: Option, + pub external_temperature: Option, + pub control_temperature: Option, + pub target_temperature: Option, + pub device_setpoint: Option, + pub outdoor_temperature: Option, + 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, diff --git a/src/queries.rs b/src/queries.rs index e29bae0..3f5aa0a 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -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 = diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index a44576c..0000000 --- a/tailwind.config.js +++ /dev/null @@ -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: [] -}; diff --git a/web/app.js b/web/app.js index 90c7083..2dcfa33 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = `${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 = `
${esc(tr('history.noData'))}
`; + 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 `
${esc(zone.name)}${fmtTemp(row.control_temperature)}${esc(tr('history.targetShort'))} ${fmtTemp(row.target_temperature)}
`; + }).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]) => `
${esc(label)}${esc(value)}${esc(detail)}
`).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=>`${esc(s.label)}`).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(()=>{})); diff --git a/web/index.html b/web/index.html index cb71af7..93cf8d7 100644 --- a/web/index.html +++ b/web/index.html @@ -90,10 +90,10 @@
-
Measurements

Temperature history

+
Measurements

Climate history

- +
-
-
TemperatureTarget
+
+
+
+

Temperature overview

GREE, room sensor, control temperature, targets and outdoor temperature.

+
+
+
+
+

Operation overview

Demand, power and fan activity over the same period.

+
+
diff --git a/web/styles.css b/web/styles.css index f1bd40c..f9015c9 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } +} diff --git a/web/sw.js b/web/sw.js index 8ba83ce..7d156ab 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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()))); diff --git a/web/tailwind.input.css b/web/tailwind.input.css deleted file mode 100644 index a3aa6ef..0000000 --- a/web/tailwind.input.css +++ /dev/null @@ -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; } -}