diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md deleted file mode 100644 index 901f799..0000000 --- a/BUILD_REPORT.md +++ /dev/null @@ -1,18 +0,0 @@ -# GREE Controller v0.8.10 - dashboard and notification-filter report - -Source baseline: GREE Controller v0.8.9. - -## Changes - -- added fine-grained notification categories with a dedicated stale Home Assistant sensor switch; -- split stale HA reads into the `ha.sensor_stale` event kind while retaining other HA failures as `ha.sensor_error`; -- rebuilt the Dashboard as horizontal **Main / Thermostats / Manual control** tabs with one vertically scrolling panel at a time; -- moved LAN discovery from the global toolbar to Devices; -- added centered `active/total running` unit status to the top toolbar; -- changed control-owner metadata to show date and time instead of time only; -- removed visible "Quick" wording from the Dashboard thermostat/manual-control section titles; -- rotated the PWA cache key for the changed frontend. - -## Validation - -The Rust toolchain is not installed in this build environment, so `cargo test`/`cargo check` cannot be run here. JavaScript syntax checks, JSON parsing, HTML ID checks, shell syntax checks, manifest regeneration and ZIP integrity verification are performed before packaging. The target-host updater should run the full Rust test suite before replacing the service. diff --git a/Cargo.lock b/Cargo.lock index 947c214..b4e644c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.10" +version = "0.8.13" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index ed3edd3..1adfdb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.10" +version = "0.8.13" 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 d96b855..985ade4 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,20 +1,11 @@ -d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example +f38d9bfbe2b378e02ab825b1f0bf6e7c6b8ac9a834c63bac58d2383524fbe8e1 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -875d85d3388362890ef0fa28b7192634856b8c095f5fbdacc75d60e014f692cc ./BUILD_REPORT.md -c91ff48b9278d8394e79e734b11cc49d0c092881500edc11121b6baca23d2e67 ./Cargo.lock -43bf5b7dc87db920fdd5a7157c50c92aef7053a293c51c86c5b5fa76c761f9d7 ./Cargo.toml +7fdaf0c83e203b81eb3d6ae6461f61481f08998bba5f5a442722e943925f5c12 ./Cargo.lock +ba5be0946d041a905c54c102e00d933204f6eec153b6a56585d4ff7b3f10b876 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -21dd3dc409d51f29890ee303f97eaa4e68362c100e499c48bfc1ef8e57f525fd ./README.md +73ff8cb22b2cfb35456e04225b59cefee3c71bd6ba0348d5ecb74e9d9f804c92 ./README.md 91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs -6e2953807ea0d1f346f99adb527391806cb088afdbc62e721193be7253a3bc9c ./docs/API.md -234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md -7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md -10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md -2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md -7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md -28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md -c3f3f12206c552d3fb919022d64b3fa60342fc1031081e893b7efd743961c402 ./future.md -95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md +751f5083ced1ac84f1841492c389fc5afaa6bef242dc7379538896428df7bbed ./docs/API.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py @@ -24,7 +15,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -98f494432dbc861b5b4b8928715845590189008482733006af0b0d9277d193a8 ./home-assistant/custom_components/gree_controller/manifest.json +ada3b5ff14063c4a88638dd6165405998fc248948a37d90a7fd34193ba10e2f0 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -32,11 +23,9 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assista 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 -29cc08123a581474a4445d8bf98dcf22f17cdd2e0e7f1358158e588e1252eec5 ./lang/en.json -166d7daf829d8b3f9127a90f2da8c3db0ee2d8b490a083d1c278e7ccbde48a27 ./lang/pl.json +0e8218afe7cddfa4519405213e67e5b84bd108c44e0b8bf2155bf94b81949399 ./lang/en.json +7ee310fa91a7be09fe4a8d4ab8ab94d651dbe0c72e512ec07517461b9381731f ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py -bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh @@ -47,7 +36,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -8295771c6907bcbc4e57d3c4572c120b72501167d02706f1fade7c1c09d6a6bf ./src/api.rs +b8f000150bac599d6a8c57b64ab3d145f2e6154ecf5e49bcd3d6cfa5241c11bc ./src/api.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs 15d07a2c0ad8ec6530897980787e6e9a94103bda849f07c233f6a37796d1f0b1 ./src/engine.rs @@ -63,10 +52,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -22c7882d8830101f21435069489dc976acb31fe027be8182c5bc444d3367c451 ./web/app.js +2e4e214a39f2ec4fab32d6cdcb480186c93a30ca7b8cac17fd11b196102324ae ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -5900f4f892c1515178c26a8a243476010513970185009a0b3d44889e0c4c9681 ./web/index.html +fe994148bb0e28027c9ddf5962a4878e2bde6d8158cbac1fb76c8363bea6405e ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -db9319c0a0c6927a4f3e2b0a2a83927dd61ad6a456e9ca8fad9f3b48d1987778 ./web/styles.css -2da305f344ba9cf2d56975fdc4c446ef47b65a117daf13e0689091bd31303f32 ./web/sw.js +cc5849d3f60fb5c72cde5995dad32a9675e826dbf6b2be5e08382e706cb66fd2 ./web/styles.css +47d57bebd83e4738f5b6c8dbbcb33d125a657b2b0c3ebcfcc2c4f35ab4484326 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index c50c7d2..797350b 100644 --- a/README.md +++ b/README.md @@ -1,609 +1,401 @@ # GREE Controller -> Identifier convention: project-owned namespace identifiers use `gree_controller`. Home Assistant uses `gree_controller`, UI storage uses `gree_controller_*`, and environment variables use `GREE_CONTROLLER_*`. +Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -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 release: 0.8.13** -Current version: **0.8.10**. +> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. -## Highlights +## What it does -- local GREE discovery over UDP/7000, -- V1 AES-128-ECB and V2 AES-128-GCM discovery/bind/status/command transport, -- automatic ECB/GCM detection from discovery responses and bind fallback, -- power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light, -- capability discovery and control for optional GREE X-FAN, Air, Health and native Sleep features, -- SQLite state/history/event storage, -- automatic SQLite history compaction plus optional InfluxDB 1.x/2.x long-term archive, -- smart thermostat zones with global house Heat/Cool/Off mode and per-zone overrides, -- setpoint modulation that keeps indoor units powered during normal operation instead of repeatedly cycling power, -- Comfort/Sleep/Away profiles, temporary overrides and one-tap **Sleep**, -- named climate groups (for example Upstairs/Downstairs) with independent power, Heat/Cool/house-follow mode and profile control, including automation targets, -- editable weekly schedules plus Family/Child room/Bedroom/Workday/Always-comfort templates, -- temperature/time automations for advanced exceptions, -- per-zone optional Home Assistant room-temperature sensors with GREE fallback, -- optional Home Assistant outdoor-temperature assist for setpoint/fan decisions, -- opt-in support for self-signed/invalid HTTPS certificates on the outbound HA sensor connection, -- combined zone temperature using configurable GREE/external sensor weighting and discrepancy protection, -- REST API and WebSocket updates, -- responsive PWA optimized for phones, -- dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes, -- Node-RED-style live automation flow board with sensor → thermostat → decision → unit command nodes, group/unit filtering, standalone-tab view and browser full-screen mode, -- configurable night mode with quiet hours, maximum fan speed and optional GREE Quiet/native Sleep requests, -- configurable event-log retention and presentation-only Home Assistant sensor aliases used by history charts/selectors, -- settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads, -- JSON-based UI localization loaded from embedded `lang/*.json` language packs, -- light, dark and system appearance modes stored in a browser cookie, -- optional Bearer-token authentication, -- simulator mode for development without physical hardware, -- Debian/Ubuntu LXC systemd installer, -- Home Assistant custom integration that proxies device `climate` commands, exposes full zone thermostat entities and publishes every climate group as a separate HA device with power, mode, profile and control-plan entities, -- migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`. +- Discovers and binds GREE Wi-Fi units on the LAN, including mixed protocol generations. +- Provides direct technical device control and a separate thermostat layer built around zones. +- Supports cooling/heating profiles: Comfort, Sleep, Away and custom quick targets. +- Coordinates house, group and per-zone control without silently fighting physical/manual overrides. +- Supports schedules, automations, night mode and temporary Quick Thermostat sessions. +- Can use GREE sensors, Home Assistant room sensors or a weighted combination. +- Stores recent history in SQLite and can archive/query long-term history in InfluxDB 1.x or 2.x. +- Exposes native Home Assistant entities through the bundled custom integration. +- Includes PWA-capable responsive UI, Polish/English localization and live diagnostics. +- Can run in Simulation mode without physical air conditioners. A large warning is shown whenever simulation is enabled. -See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow. +## UI overview -## Quick start +The interface is intentionally split by responsibility: -On Debian, Ubuntu or an LXC container: +| Area | Purpose | +| --- | --- | +| Dashboard | Whole-house summary and Quick Thermostat controls. | +| Devices | Discovery, binding and direct/technical GREE device control. | +| Zones | Thermostat configuration, profiles, sensors and control ownership. | +| Groups | Climate group membership and group power/mode/preset control. | +| Schedules | Weekly thermostat schedules and ready-made templates. | +| Automations | Time/temperature triggered device or group actions. | +| History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. | +| Simulation | Simulated units for safe testing. | +| Night mode | Quiet/Sleep/fan limits during an overnight window. | +| Home Assistant | Sensor connection, aliases and restricted integration tokens. | +| Settings → Application | Simulation, notifications, metrics/logs, InfluxDB, debug and backup. | +| Settings → GREE | Controller identity, polling/discovery, GREE commands and live GREE traffic. | +| Events | Operational event log and diagnostics. | + +The top bar uses the same compact language and theme controls on desktop and mobile. The live debug overlay can be filtered to **All**, **Requests** or **GREE** frames. + +## Quick start for development + +Requirements are installed automatically on supported Debian/Ubuntu environments unless `--no-install` is used. ```bash -unzip gree-controller-v0.8.10.zip -cd gree-controller -chmod +x scripts/*.sh +cp .env.example .env ./scripts/dev.sh ``` -`scripts/dev.sh` installs missing build tools when possible, installs stable Rust with `rustup` when required, creates `.env`, builds the application and starts the web panel. - -Default address: - -```text -http://HOST_ADDRESS:8787 -``` - -The first empty database can be seeded with **Living Room (simulator)** so the UI, history, zones and automations can be tested without an AC. - -### Development commands +Useful development commands: ```bash -./scripts/dev.sh --check -./scripts/dev.sh --release -./scripts/dev.sh --reset -./scripts/dev.sh --host 0.0.0.0 --port 8787 -./scripts/dev.sh --no-install +./scripts/dev.sh --check # format, tests, build and HTTP smoke test +./scripts/dev.sh --release # optimized local build +./scripts/dev.sh --reset # recreate the local SQLite database +./scripts/smoke.sh # API smoke test against an isolated simulated instance ``` -## Web interface +The default panel is available at `http://127.0.0.1:8787` when the process is bound locally. -The UI is mobile-first and uses no external CDN. +## LXC / systemd installation -Language selector: +The production scripts target Debian/Ubuntu systemd hosts and LXC containers. -- English is the required default/fallback language, -- Polish is included, -- additional languages are discovered automatically from `lang/*.json` at build time. - -The selected language is stored in the `gree_controller_language` cookie. The UI contains no hard-coded list of supported languages. To add a language, copy `lang/en.json`, translate the `translations` values, set the `meta` fields, save it as `.json`, and rebuild. For example, `lang/de.json` becomes an additional language after `cargo build` / `./scripts/dev.sh`. Missing translation keys fall back to English. - -See [`docs/LOCALIZATION.md`](docs/LOCALIZATION.md) for the language-pack format and validation rules. - -Appearance selector: - -- System, -- Light, -- Dark. - -The selected appearance is stored in the `gree_controller_theme` cookie. The current UI keeps the classic GREE Controller interface: large rounded cards, circular thermostat controls, compact desktop navigation and mobile bottom navigation. It keeps the newer neutral dark/light palette and green accent (`#3ecf8e` in dark mode and `#24b47e` in light mode). - -### Static offline CSS - -The interface is plain static CSS. `web/styles.css` is committed with the project and embedded directly into the Rust binary. There is no frontend package manager, CDN, or CSS build step. Edit `web/styles.css` and `web/index.html` directly, then rebuild the Rust application. - - -## Connecting a physical GREE device - -1. Put the controller and AC in a network where UDP/7000 traffic is allowed. -2. Open the web interface and select **Discover**. -3. Start with **Auto (V1 + V2)** and 3 scan passes. The controller recognizes ECB responses and GCM responses carrying a `tag` and automatically binds discovered units. -4. If required, repeat discovery using **V1 AES-ECB** or **V2 AES-GCM** to isolate a model family. -5. New devices immediately open a naming step so you can enter room-friendly names such as **Salon** or **Sypialnia**. They can also be renamed later from **Devices -> Rename**. Re-discovery preserves your custom name. -6. If LXC/VLAN broadcast does not pass, add the unit manually with IP and MAC/CID. - -Version 0.3.7 additionally uses forgiving GREE Base64 decoding for non-canonical GCM tags seen on real Wi-Fi modules, automatically selects the directly connected local IPv4 interface for unicast GREE traffic, and refreshes the short bind window with a subnet broadcast immediately before bind. A single lost UDP response no longer marks a device offline; three consecutive communication failures are required. - -## LXC/systemd installation and updates - -All operator scripts are under `scripts/`. On a clean Debian/Ubuntu LXC container: +### First installation ```bash -chmod +x scripts/*.sh sudo ./scripts/install.sh ``` -The installer installs build dependencies/Rust when required, runs the Rust tests, builds a release binary, creates the `gree-controller` service account, stores runtime data in `/var/lib/gree-controller`, installs the binary under `/opt/gree-controller`, creates `/etc/gree-controller.env`, generates an administrator token and enables the systemd service. Existing `/etc/gree-controller.env` is preserved. +Optional flags: -For a later release, unpack the new source archive and run: +```bash +sudo ./scripts/install.sh --skip-tests +sudo ./scripts/install.sh --no-start +``` + +Installed layout: + +| Path | Purpose | +| --- | --- | +| `/opt/gree-controller/gree-controller` | Release binary. | +| `/etc/gree-controller.env` | Environment configuration; mode `0600`. | +| `/var/lib/gree-controller/gree-controller.db` | SQLite database. | +| `/etc/systemd/system/gree-controller.service` | systemd unit. | +| `/var/backups/gree-controller/` | Update backups used for rollback. | + +### Update + +Run from the extracted source tree of the new release: ```bash sudo ./scripts/update.sh ``` -The update is designed for LXC testing and production-style upgrades: compilation/tests happen before the running service is stopped; then the script backs up the installed binary, service unit, environment file and stopped SQLite database under `/var/backups/gree-controller//`. After replacement it checks `/api/health`. A failed startup triggers automatic rollback to the previous binary, unit and database backup. +The updater builds/tests first, stops the old service, backs up the binary/unit/environment/database, installs the release, checks `/api/health` and rolls back automatically if startup fails. -Service helpers: +### Service helper ```bash ./scripts/service.sh status -sudo ./scripts/service.sh restart -./scripts/service.sh logs ./scripts/service.sh health +./scripts/service.sh logs +sudo ./scripts/service.sh restart ``` -Use `--skip-tests` with `install.sh` or `update.sh` only when you explicitly want to skip `cargo test --all-targets`. `scripts/install-lxc.sh` remains as a compatibility alias to `scripts/install.sh`. +`./scripts/install-lxc.sh` remains as a compatibility alias to `install.sh`. -## Environment configuration +## Configuration -| Variable | Default | Purpose | -|---|---:|---| -| `GREE_CONTROLLER_BIND` | `0.0.0.0:8787` | HTTP/WebSocket bind address | -| `GREE_CONTROLLER_DATABASE` | `./data/gree-controller.db` | SQLite file | -| `GREE_CONTROLLER_APP_TOKEN` | empty | Bearer token; empty disables API authentication | -| `GREE_CONTROLLER_SIMULATE` | `false` | Enables simulator seeding for development/testing | -| `GREE_CONTROLLER_AUTO_SEED` | `false` | Seeds a simulator into an empty database when simulation is enabled | -| `GREE_CONTROLLER_POLL_INTERVAL_SECONDS` | `15` | Device polling interval | -| `GREE_CONTROLLER_ZONE_INTERVAL_SECONDS` | `5` | Zone-control interval | -| `GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS` | `3000` | UDP discovery timeout | -| `GREE_CONTROLLER_DISCOVERY_BROADCAST` | `255.255.255.255:7000` | Discovery broadcast target | -| `GREE_CONTROLLER_GREE_INTERFACE` | empty | Optional Linux interface used for all GREE UDP traffic, e.g. `eth1` | -| `GREE_CONTROLLER_ID` | `gree-controller` | Controller instance identifier used for logs/metadata; the GREE wire protocol uses the standard `cid=app` | -| `GREE_CONTROLLER_HOUSE_MODE` | `cool` | Initial seasonal house mode: `cool`, `heat` or `off` | -| `GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED` | `true` | Initial outdoor-temperature assist state | -| `GREE_CONTROLLER_HISTORY_RETENTION_DAYS` | `30` | Local SQLite retention before pruning | -| `GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED` | `true` | Compact old local samples to chart-oriented resolution | -| `GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP` | `false` | Send only changed fields and request GREE buzzer suppression where supported | -| `GREE_CONTROLLER_INFLUX_ENABLED` | inferred from URL | Enable optional long-term InfluxDB archive | -| `GREE_CONTROLLER_INFLUX_VERSION` | `2` | `1` for InfluxDB 1.x or `2` for InfluxDB 2.x | -| `GREE_CONTROLLER_INFLUX_URL` / `INFLUXDB_URL` | empty | InfluxDB base URL | -| `GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS` | `30` | Read history older than this age from InfluxDB | -| `GREE_CONTROLLER_INFLUX_DATABASE` | `gree_controller` | InfluxDB 1.x database | -| `GREE_CONTROLLER_INFLUX_USERNAME` / `GREE_CONTROLLER_INFLUX_PASSWORD` | empty | Optional InfluxDB 1.x credentials | -| `GREE_CONTROLLER_INFLUX_ORG` / `GREE_CONTROLLER_INFLUX_BUCKET` / `GREE_CONTROLLER_INFLUX_TOKEN` | empty | InfluxDB 2.x organization, bucket and token | -| `GREE_CONTROLLER_DEBUG_OVERLAY` | `false` | Show the live debug window on every web view | -| `GREE_CONTROLLER_DEBUG_GREE_FRAMES` | `false` | Stream decrypted GREE request/response payloads into debug | -| `HA_URL` | empty | Optional Home Assistant URL | -| `HA_TOKEN` | empty | Optional Home Assistant Long-Lived Access Token | -| `HA_ENTITY_ID` | empty | Optional default HA room-temperature sensor | -| `HA_OUTDOOR_ENTITY_ID` | empty | Optional HA outdoor-temperature sensor | -| `HA_ALLOW_INVALID_TLS` | `false` | Opt in to invalid/self-signed HA HTTPS certificates | +Copy `.env.example` for the full supported example. Command-line arguments exist for the core `Config` options, while most day-to-day runtime settings are also editable in the Web UI and persisted in SQLite. -Settings changed from the web panel are stored in SQLite. Explicit history/debug/Influx environment variables override their persisted values at process startup. Supplying an Influx URL enables the archive unless `GREE_CONTROLLER_INFLUX_ENABLED=false` is explicitly set. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup. +Environment values explicitly supplied for supported runtime overrides win over the persisted value after restart. -Web UI organization: **Settings** contains controller/application and technical storage/debug options, while **Night mode** and **Home Assistant / Sensors** are separate pages under More. Disabled zones continue reporting room temperature even though thermostat control is stopped. +### Core variables -## Climate history +| Variable | Default | Description | +| --- | --- | --- | +| `GREE_CONTROLLER_BIND` | `0.0.0.0:8787` | HTTP/WebSocket listen address. | +| `GREE_CONTROLLER_DATABASE` | `./data/gree-controller.db` | SQLite database path. | +| `GREE_CONTROLLER_APP_TOKEN` | empty | Optional administrator API/Web UI token. Empty means trusted-LAN mode. | +| `GREE_CONTROLLER_BASE_PATH` | empty | Optional reverse-proxy prefix such as `/gree`. | +| `GREE_CONTROLLER_ID` | `gree-controller` | GREE client/controller identifier. | +| `GREE_CONTROLLER_SIMULATE` | `false` | Initial Simulation mode. | +| `GREE_CONTROLLER_AUTO_SEED` | `false` | Seed simulated sample data when applicable. | +| `GREE_CONTROLLER_POLL_INTERVAL_SECONDS` | `15` | Device polling interval. | +| `GREE_CONTROLLER_ZONE_INTERVAL_SECONDS` | `5` | Thermostat control interval. | +| `GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS` | `3000` | Discovery timeout. | +| `GREE_CONTROLLER_DISCOVERY_BROADCAST` | `255.255.255.255:7000` | GREE discovery target. | +| `GREE_CONTROLLER_GREE_INTERFACE` | auto | Optional interface name or local IP used for GREE UDP traffic. | -History is collected independently for physical GREE devices, control zones, and configured Home Assistant temperature sensors. Device samples continue to use the long-standing `readings` table, so upgrading does not require waiting for a new zone-history table to fill. When rich `zone_readings` are not available yet, the API automatically reconstructs a compatible zone timeline from existing device readings. +Additional environment variables cover history retention, debug, night mode, Home Assistant and InfluxDB. See [`.env.example`](.env.example). -The History area has linkable sub-pages: +## Connecting physical GREE units -- `/history/overview` — all GREE indoor temperatures, available GREE/HA outdoor temperatures, and all zone control temperatures, -- `/history/zones` — all zones or one detailed zone with GREE, HA room, control, target and device-setpoint series, -- `/history/devices` — indoor temperature, available `OutEnvTem`, and target for every GREE unit, -- `/history/sensors` — configured Home Assistant room/outdoor temperature sensors, -- `/history/custom` — compose arbitrary series, save chart definitions in the browser and copy a URL that recreates the chart. +1. Place the controller host on a network that can reach the air-conditioner Wi-Fi modules by UDP. +2. Open **Devices** and start discovery. +3. Auto protocol mode is recommended; the controller accepts both supported GREE encryption generations. +4. Newly discovered devices are bound automatically when possible. Manual bind is also available. +5. Create a thermostat zone for each unit you want the thermostat engine to own. -A zone timeline includes GREE indoor temperature, optional HA room temperature, calculated control temperature, active profile target, actual setpoint sent to the AC, available outdoor temperature, demand, power, mode and fan speed. HA sensor samples and zone samples are throttled to at least 15 seconds (or the configured device poll interval, whichever is longer). Local samples older than 24 hours are compacted to 10-minute resolution and samples older than 7 days to 30-minute resolution. When InfluxDB is configured, older queries are merged from InfluxDB with recent SQLite history. The maintenance task backfills compacted legacy samples into InfluxDB and deletes an old SQLite sample only after the archive write succeeds, so an unavailable archive does not cause local data loss. +Discovery uses UDP broadcast, so routed/VLAN networks must explicitly permit or relay the required traffic. -All main UI views also use browser URLs (`/dashboard`, `/devices`, `/zones`, `/groups`, `/schedules`, `/automations`, `/simulation`, `/settings`, `/events`) so refresh, browser Back/Forward, bookmarks and direct links work normally. +### Multi-NIC / dedicated GREE interface -## Per-zone room temperature sensors +For a host with separate management and GREE networks, set for example: -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. - -Example: - -```text -Living room -> GREE Living Room + sensor.living_room_temperature -Bedroom -> GREE Bedroom + sensor.bedroom_temperature -Office -> GREE Office + sensor.office_temperature +```bash +GREE_CONTROLLER_GREE_INTERFACE=eth1 ``` -Zone temperature strategies: +or a local address: -- **GREE only** — use the AC internal sensor. -- **GREE + room sensor** — recommended; calculate a weighted control temperature from both sensors. The default room-sensor weight is 40%. -- **Room sensor only** — use the assigned HA room sensor, with automatic fallback to GREE if HA or the entity becomes unavailable. +```bash +GREE_CONTROLLER_GREE_INTERFACE=192.168.50.2 +``` -For combined control, `max_sensor_difference` protects against an obviously incorrect external measurement. If the two sensors differ by more than the configured threshold (default `3.0°C`), the zone uses the GREE temperature and logs a sensor-discrepancy event. +The controller will use that interface for GREE UDP traffic while keeping the HTTP UI on `GREE_CONTROLLER_BIND`. -The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload. +Diagnostics: -Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Away**, **Global / Heat / Cool**, and local thermostat **ON/OFF** are available without opening the edit dialog. Local ON/OFF is thermostat ownership, not direct/pilot control: a locally started zone can bypass a disabled climate-group power gate while still using its room sensor, hysteresis, selected target/profile, Smart Fan and GREE setpoint modulation. **Resume automation** removes that local power override and returns the zone to group/schedule ownership; if its group is off, the unit then stays off. Only a real physical/remote change or the technical Devices command path creates `device_manual_override`. Controller-originated GREE transitions are tracked through the short device settling window so group/thermostat commands are not misclassified as remote takeover. Temporary thermostat overrides otherwise end at the next schedule boundary; when a local thermostat is explicitly active, its selected manual target/profile is retained until Auto/Resume is chosen. +```bash +sudo ./scripts/configure-gree-network.sh +./scripts/network-debug.sh +``` -## Smart thermostat and schedules +The hardened systemd unit allows `AF_NETLINK`, which is required for interface discovery on multi-NIC Linux/LXC systems. -Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. Smart Quiet and standby Low fan are transition hints rather than commands that are blindly reasserted every adjustment interval, avoiding unnecessary beeps when the room and device setpoint are already stable. **House Off** is now a no-control state for inherited zones, so manual device operation is not overwritten; whole-house master power on/off is available separately and can authoritatively keep every unit off without changing the selected thermostat mode. Disabled zones are excluded from normal house/group/automation control; their physical unit remains available for explicit technical control from the Devices view. +## Thermostat model -Choose one seasonal **global mode** — Cooling in summer, Heating in winter, or Off. Zones inherit this global mode by default, while a zone may explicitly override Heat/Cool when required. In **Mode policy**, leave **Follow global mode** unchanged when the zone should inherit the mode selected globally in GREE Controller; **Cooling only** / **Heating only** are deliberate per-zone overrides. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating. +### Devices vs zones -Schedules store profiles rather than duplicating temperatures. Ready-made **Family**, **Child room**, **Bedroom**, **Workday** and **Always comfort** templates generate ordinary editable schedule entries. For example, a child room template can enter Sleep earlier; if the child goes to bed even earlier, press **Sleep** and that zone remains in Sleep until its next schedule boundary (or until manually cleared when no future boundary exists). Whole-house **Comfort all / Sleep all / Away all / Auto schedule** actions are also available. +A **Device** is the physical GREE unit. Device commands are direct/technical commands. -The optional HA outdoor sensor is an assist signal only. It never replaces the room-control temperature. In extreme weather it can make the active device setpoint slightly more assertive and let Smart Fan increase airflow. When cooling is actually controlled from a valid external/combined room sensor, demand also forces the next lower whole-degree unit target (typically 0.5–1.0°C below the room target); the correction is disabled on GREE fallback and is not stacked above the outdoor-assist maximum. +A **Zone** is the thermostat owner for a device. It decides demand from room temperature, setpoint, hysteresis, schedules, group/house gates, manual ownership and safety lockouts. -At startup, thermostat, schedule and automation commands are held until one complete live device poll has finished. This prevents a restart from issuing commands based only on stale persisted device state while still preserving all saved zone, group, schedule, override and device configuration. +This distinction is important: direct device control intentionally behaves differently from thermostat control. + +### Control ownership + +The zone state exposes who currently owns control (`control_owner`, `control_source`, timestamps and reason). Physical remote/direct device actions can create a manual-device takeover so normal schedules do not immediately overwrite the user. Explicit zone thermostat control hands ownership back to the thermostat engine when appropriate. + +Whole-house master OFF remains authoritative and powers managed devices down. + +### Profiles and schedules + +Each zone stores separate cooling and heating temperatures for: + +- Comfort +- Sleep +- Away +- Custom/manual target + +Schedules select profiles or a custom target for selected ISO weekdays (`1=Monday`, `7=Sunday`). Overnight windows are supported. Overlapping enabled schedules for the same zone are rejected. + +Quick preset/setpoint overrides normally hand control back at the next schedule boundary. Temporary Quick Thermostat adds explicit start/finish rules such as duration, exact time, temperature reached/stable or next schedule boundary. ## Home Assistant -There are two independent HA directions: +There are two independent Home Assistant directions. -1. **HA as an optional sensor source**: the Rust controller can read a selected HA sensor and use it for a zone. -2. **HA as a client of GREE Controller**: the included custom integration creates `climate` entities whose commands are sent to this Rust application. +### 1. Home Assistant as a temperature source -For the HA client connection, open **More -> Home Assistant / Sensors** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/control-plan/house-control/zone-control API; they cannot change controller settings or manage other tokens. - -The outbound HA sensor client also supports local HTTPS endpoints such as `https://192.168.50.25`. If the server uses a self-signed, expired or hostname-mismatched certificate, enable **Home Assistant / Sensors -> Allow invalid/self-signed HTTPS certificate**. This disables certificate/hostname validation only for the controller's outbound Home Assistant sensor client and should only be used on a trusted LAN. - -The second option is designed to replace the built-in/default GREE integration without changing automation/dashboard references. - -Example migration target: +Configure **Home Assistant / Sensors** in the Web UI or use: ```text -old: climate.klima_salon -> built-in GREE integration -new: climate.klima_salon -> GREE Controller custom integration -> Rust API -> AC +HA_URL= +HA_TOKEN= +HA_ENTITY_ID= +HA_OUTDOOR_ENTITY_ID= +HA_SENSOR_STALE_AFTER_SECONDS=300 +HA_ALLOW_INVALID_TLS=false ``` -Generate an entity mapping: +Per zone, `sensor_source` can be: -```bash -./scripts/generate_ha_migration.py \ - --entity climate.klima_salon \ - --device gree-aabbccddeeff -``` +- `device` — GREE indoor sensor, +- `home_assistant` — configured HA room sensor, +- `combined` — weighted GREE + HA value. -Or validate against the controller and automatically use its only device: +Stale/unavailable external data falls back to the GREE sensor when possible. The optional outdoor sensor is only an assist signal; it never replaces room temperature. -```bash -./scripts/generate_ha_migration.py \ - --entity climate.klima_salon \ - --controller-url http://192.168.1.20:8787 \ - --controller-token YOUR_CONTROLLER_TOKEN -``` +### 2. GREE Controller entities inside Home Assistant -The generated file must be copied to: +Bundled integration directory: ```text -/config/gree_controller_entities.json +home-assistant/custom_components/gree_controller/ ``` -Full installation and safe takeover procedure: [`home-assistant/README.md`](home-assistant/README.md) and [`docs/HOME_ASSISTANT_MIGRATION.md`](docs/HOME_ASSISTANT_MIGRATION.md). +Copy `gree_controller` to Home Assistant's `custom_components` directory, restart Home Assistant and add **GREE Controller** from Integrations. -## API +Create a restricted token in **Home Assistant / Sensors → Access tokens**. The token secret is displayed only once and the database stores only its SHA-256 hash. The integration uses a restricted `/api/integrations/home-assistant/*` surface rather than administrator endpoints. -Important endpoints: +The integration exposes physical device climate controls, thermostat zone climate entities, whole-house controls, groups and optional unit capabilities. Prefer zone thermostat entities for normal comfort control; physical device entities are direct/manual controls. -```text -GET /api/health -GET /api/bootstrap -POST /api/discovery -GET /api/devices -POST /api/devices -GET /api/devices/:id -PATCH /api/devices/:id -POST /api/devices/:id/bind -POST /api/devices/:id/poll -POST /api/devices/:id/command -GET /api/zones -POST /api/zones -POST /api/zones/:id/control -POST /api/zones/:id/schedule-template -GET /api/groups -POST /api/groups -GET /api/groups/:id -PUT /api/groups/:id -DELETE /api/groups/:id -POST /api/groups/:id/control -POST /api/house/control -POST /api/house/power -POST /api/house/preset -GET /api/schedules -POST /api/schedules -GET /api/automations -POST /api/automations -GET /api/readings -GET /api/history -GET /api/control-plan -GET /api/events -GET /api/settings -PUT /api/settings -GET /api/settings/export -POST /api/settings/import -GET /api/debug -PUT /api/debug -GET /api/access-tokens -POST /api/access-tokens -DELETE /api/access-tokens/{id} -POST /api/integrations/home-assistant/test -GET /api/integrations/home-assistant/devices -POST /api/integrations/home-assistant/devices/{id}/command -GET /api/integrations/home-assistant/control-plan -GET /api/integrations/home-assistant/groups -POST /api/integrations/home-assistant/groups/{id}/control -POST /api/integrations/home-assistant/house/control -POST /api/integrations/home-assistant/house/preset -POST /api/integrations/home-assistant/house/power -POST /api/integrations/home-assistant/zones/{id}/control -WS /ws -``` - -Example command: +For entity-ID migration tooling: ```bash -curl -X POST http://127.0.0.1:8787/api/devices/sim-salon/command \ - -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer YOUR_TOKEN' \ - -d '{"power":true,"mode":"cool","target_temperature":22,"fan_speed":3}' +python3 scripts/generate_ha_migration.py --help ``` -`/api/health` is public. The normal controller API and Web UI require `GREE_CONTROLLER_APP_TOKEN` only when that administrator token is configured. The dedicated `/api/integrations/home-assistant/*` client endpoints always require either a generated HA access token or the administrator token. WebSocket accepts only the administrator token using `?token=...`. +The generated mapping example is under `home-assistant/generated/`. -More examples: [`docs/API.md`](docs/API.md). +## History and InfluxDB + +SQLite is the local hot store. History compaction reduces older local sample density while preserving the resolutions needed by charts. Retention is configurable from the UI or environment. + +Optional InfluxDB writes samples in parallel and can serve the older part of long history queries: + +- InfluxDB 1.x: URL, database, optional username/password. +- InfluxDB 2.x: URL, organization, bucket and token. + +If an Influx query fails, the API returns available SQLite data and can include a `storage_warning` rather than discarding the whole history response. + +See [`GET /api/history`](docs/API.md#history-and-readings) for scopes and bucket behavior. + +## Notifications + +Optional notifications support: + +- Pushover +- Slack webhook +- Discord webhook + +Modes can report problems only or problems plus important state changes. Individual categories include sensor freshness/errors, communication failures, target timeout, automation/control errors and other warnings. Cooldown and failure thresholds are configurable. + +## Debugging + +Enable **Settings → Application → On-screen debug**. + +The overlay has three filters: + +- **All** — request diagnostics, application events and GREE frames, +- **Requests** — HTTP method/path/status/duration, +- **GREE** — sanitized protocol frames when GREE frame logging is enabled. + +Binding/encryption keys are not intentionally exposed in GREE debug frames. + +For Linux/network issues also use: + +```bash +./scripts/network-debug.sh +./scripts/service.sh logs +``` + +## Backup and restore + +**Settings → Application → Configuration backup** exports settings, devices, zones, groups, schedules and automations. + +The export intentionally does **not** contain metric history, event history or generated API-token records. It **does contain** GREE binding keys and configured integration secrets, so store it like a credential file. + +Import replaces application configuration while preserving local metric/event history and generated access-token records. Runtime ownership/timers are sanitized and live device state is re-polled before automatic control resumes. + +## Reverse proxy and sub-path deployment + +For a root deployment, proxy HTTP and WebSocket traffic to the controller normally. + +For a sub-path, configure: + +```bash +GREE_CONTROLLER_BASE_PATH=/gree +``` + +Then serve the application under `/gree/`. The proxy must preserve WebSocket upgrade headers. A typical nginx location is: + +```nginx +location /gree/ { + proxy_pass http://127.0.0.1:8787/gree/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; +} +``` + +If your proxy strips the prefix, keep its upstream/base-path handling consistent. Do not publish the controller directly to an untrusted network without authentication and TLS at the proxy/VPN layer. ## Security -- The application does not terminate TLS. Use HTTPS reverse proxy or VPN on untrusted networks. -- Do not expose port 8787 directly to the public Internet. -- Use a long random `GREE_CONTROLLER_APP_TOKEN`. -- Protect `.env`, `/etc/gree-controller.env`, SQLite data and HA tokens. -- The controller sends device commands only to configured local-network devices. +The default empty `GREE_CONTROLLER_APP_TOKEN` is intended for a trusted LAN only. Set a long random token when the interface is reachable by other networks. -## Backup +Implemented safeguards include: -For a systemd installation: +- optional bearer administrator authentication, +- separate restricted generated tokens for Home Assistant, +- hashed generated-token storage, +- secrets omitted from normal settings responses, +- restrictive security headers and no permissive CORS, +- API responses marked `no-store`, +- hardened systemd service, +- safe device power-off behavior when thermostat ownership is removed, +- startup synchronization before the thermostat engine begins issuing commands. + +For remote access, prefer a VPN or authenticated TLS reverse proxy. Treat configuration exports and `/etc/gree-controller.env` as secrets. + +## Localization + +Language packs are JSON files under `lang/`. The build validates/embeds them and generates the public language catalog. + +To add a language: + +1. Copy `lang/en.json` to `lang/.json`. +2. Update `meta.code`, names and locale. +3. Keep the same translation keys as the reference pack. +4. Rebuild the Rust binary. + +Runtime assets are available at `/lang/index.json` and `/lang/.json`. + +## API + +The HTTP API is the same backend used by the Web UI. It includes devices, zones, groups, house control, schedules, automations, history, control plan, events, settings, backup/restore, debug, tokens and integration tests. + +Start here: + +**[docs/API.md — complete API reference](docs/API.md)** + +Public health check: ```bash -systemctl stop gree-controller -cp /var/lib/gree-controller/gree-controller.db /safe/backup/location/ -systemctl start gree-controller +curl http://127.0.0.1:8787/api/health ``` -Development data is stored under `data/` by default. +Authenticated example when an app token is configured: + +```bash +curl -H 'Authorization: Bearer YOUR_TOKEN' \ + http://127.0.0.1:8787/api/system/info +``` ## Project layout ```text -src/api.rs HTTP API, WebSocket, embedded web assets -src/db.rs SQLite persistence and row/domain mapping -src/queries.rs all SQLite schema and SQL statements -src/engine.rs polling, zones, schedules and automations -src/protocol/ GREE UDP/AES discovery, bind, status, command -src/home_assistant.rs optional HA sensor client -web/ mobile-first bilingual PWA -home-assistant/custom_components/ HA custom integration -scripts/install.sh first LXC/systemd installation -scripts/update.sh safe LXC/systemd update with backup/rollback -scripts/service.sh service status/start/stop/restart/logs/health -scripts/dev.sh development build/run/check workflow -scripts/generate_ha_migration.py legacy HA entity-ID mapping generator -scripts/smoke.sh API smoke test -systemd/ systemd service unit +src/ Rust backend, GREE protocol and thermostat engine +web/ Embedded Web UI / PWA +lang/ Runtime language packs +home-assistant/ Home Assistant custom integration and migration output +scripts/ Development, install, update, service and diagnostics +systemd/ Production service unit +docs/API.md Complete API documentation +.env.example Environment reference ``` +The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. + +## Validation before release + +Recommended full check on a host with Rust installed: + +```bash +./scripts/dev.sh --check +``` + +This runs formatting checks, Rust tests, a build and an isolated simulated API smoke test. + ## License -MIT. This project uses a community-reconstructed local device protocol and is not an official product of GREE Electric Appliances Inc. - - -## Multi-NIC / dedicated GREE interface - -For LXC hosts with a management NIC and a separate GREE/VLAN NIC, bind GREE UDP traffic explicitly to the GREE interface: - -```env -GREE_CONTROLLER_GREE_INTERFACE=eth1 -GREE_CONTROLLER_DISCOVERY_BROADCAST=auto -GREE_CONTROLLER_SIMULATE=false -GREE_CONTROLLER_AUTO_SEED=false -``` - -`GREE_CONTROLLER_GREE_INTERFACE` is resolved to the interface current IPv4 address whenever a UDP socket is created, so DHCP address changes do not require hard-coding the source IP. `GREE_CONTROLLER_DISCOVERY_BROADCAST=auto` derives the subnet broadcast from the selected interface and overrides an older runtime value stored in SQLite. - -Run `sudo ./scripts/network-debug.sh eth1 10.87.65.127` to verify addressing and routing. - -### v0.3.7 LXC networking fix - -The systemd service explicitly allows `AF_NETLINK`. This is required by Linux `getifaddrs()` when resolving `GREE_CONTROLLER_GREE_INTERFACE` on multi-NIC LXC installations. Without it, older releases could fail with `Address family not supported by protocol (os error 97)` even though the configured interface was correct. - -### v0.3.8 legacy V1 device compatibility - -GREE protocol identifiers are now emitted as canonical lowercase hexadecimal in `tcid` and `pack.mac`. This improves compatibility with older V1 Wi-Fi modules (including devices in the `502cc6...` family) that can answer discovery but silently ignore bind/status packets when MAC casing differs from their discovery identity. Stored device IDs and friendly names are not changed. - -### v0.4.0 smart thermostat - -Zones now use setpoint modulation, global seasonal house mode, cooling/heating Comfort/Sleep/Away profiles, temporary per-zone overrides, ready-made editable schedule templates, whole-house preset actions and optional outdoor-temperature assist. The UI also uses theme-aware thin scrollbars and can opt in to invalid/self-signed certificates for the outbound Home Assistant sensor connection. Existing zones remain readable; a legacy zone keeps its previous setpoint as its comfort target until it is edited and saved with the new profile fields. - - -## 0.7.1 quick-control visual consistency - -- Unified dashboard Quick Thermostats and Quick Manual Control under shared `quick-control-*` CSS classes instead of separate dashboard-ID overrides. -- Standardized compact option buttons, gaps and row grids across presets, HVAC modes, fan speeds and quick unit toggles. -- Kept full Zones and Devices pages at their existing larger control sizes; the compact style applies only to dashboard quick controls. -- Renamed the compact zone mode from House to Global to make its meaning explicit: the zone inherits the global Heating/Cooling mode selected in GREE Controller. -- Renamed Sleep now to Sleep in quick thermostat presets. -- Advanced the PWA cache key to `gree-controller-v071-quick-controls`. - -## 0.7.2 zone configuration separation - -- The **Zones** view is now configuration-only: no live +/- temperature, preset or mode buttons are shown there. -- Current zone status remains visible as read-only context, together with mode policy, sensor strategy, hysteresis and Smart Fan configuration. -- The **Control** button opens the Dashboard, expands **Quick thermostats** and scrolls to the matching zone. -- The zone form no longer exposes the legacy/base `setpoint` as a live-looking control; profile temperatures remain configurable. -- Live zone temperature, preset, mode and enable/disable controls remain available in **Dashboard -> Quick thermostats**. - -## 0.7.3 Home Assistant climate groups - -- Added climate groups to the restricted Home Assistant API. -- Each configured group is exposed as its own Home Assistant device with **Power**, **Thermostat mode**, **Work profile** and **Control plan** entities. -- Group mode options are **Global**, **Cooling** and **Heating**; Global means the member zones inherit the controller-wide Heating/Cooling selection. -- Group profile options are **Auto schedule**, **Comfort**, **Sleep** and **Away**. -- The group control-plan sensor reports member zones, online unit count, current average temperature, demand count and upcoming member schedule events. -- Mixed member modes/profiles are reported without inventing a false common value; choosing a group option normalizes all member zones through the existing group-control engine. -- Group commands use the restricted Home Assistant access token and the dedicated `/api/integrations/home-assistant/groups/*` endpoints. -- After adding/removing/renaming groups, reload the GREE Controller integration in Home Assistant so its entity/device registry is rebuilt. - -## 0.7.6 Home Assistant command-state stability - -- Physical-unit commands now tolerate the short GREE firmware settling window after a successful command ACK before publishing a contradictory status. This prevents stale immediate polls from producing visible `ON -> OFF -> ON` state bounce. -- The Home Assistant coordinator keeps a just-accepted device command as a short-lived pending value while refreshing controller metadata. An overlapping stale read cannot temporarily roll back the HA climate/feature state; normal factual polling resumes automatically after the bounded guard window. - -## 0.7.5 group power state visibility - -- Group **ON/OFF** buttons now use the same strong active-state highlight as group mode and profile controls, so the current power gate is immediately visible. -- Simulator summary wording now uses **Zones with demand** / **Strefy z zapotrzebowaniem** instead of the less natural comfort-request wording. - -## 0.7.4 manual remote override - -- Physical GREE changes detected during polling for power, HVAC mode, target temperature and meaningful fan-speed changes now enter a per-zone **Manual control / remote** takeover state. -- While takeover is active, thermostat modulation, schedules, groups and direct-device automations observe the zone but do not overwrite the unit. -- The pre-takeover climate state is remembered. If the user returns the unit to that operational state, takeover clears automatically and **Resume automation** is no longer shown. If the unit was OFF before takeover, returning it to OFF is enough even when the remote retained a different dormant setpoint. Otherwise takeover expires at the next schedule transition when available; without one it remains until **Resume automation** or another explicit quick-thermostat action. -- Technical climate control from the Devices view uses the same takeover semantics, while unrelated controls such as panel light do not pause the thermostat. -- Disabled zones remain outside normal group/automation ownership; their unit can be operated manually. -- **Turn all off** remains authoritative: it clears all takeovers, disables groups and powers off enabled units. Whole-house/group ON paths do not wake zones currently protected by manual takeover. -- The UI and control-plan API expose the takeover state and its optional expiry. - - -## 0.7.7 Quick-control label fit - -- Adjusted the five-column quick-device mode row so longer Polish labels such as **Chłodzenie** and **Osuszanie** receive more width without reducing the whole row font size. -- The change is limited to the dashboard quick-device mode row; thermostat/group layouts are unchanged. -- Rotated the PWA cache key so browsers fetch the corrected stylesheet after upgrade. - - - - - - -## 0.8.10 Dashboard sections and notification filters - -- Dashboard is split into three horizontal tabs: **Main**, **Thermostats** and **Manual control**. Each tab opens one normal vertically scrolling section instead of nested disclosure panels. -- Main keeps the summary, global power/house climate and control plan; Thermostats contains group controls and zone thermostats; Manual control contains direct unit controls. -- The top toolbar shows the live powered-unit count (`active/total`), and **Discover** moved from the global toolbar to the Devices page. -- Runtime control ownership timestamps now include both date and time. -- Notification settings now expose per-category delivery switches. Stale Home Assistant readings have their own `ha.sensor_stale` category and can be suppressed without disabling other sensor, communication, automation or thermostat alerts. -- Rotated the PWA cache key so upgraded clients fetch the new dashboard and settings UI. - -## 0.8.9 Remote-takeover diagnostics - -- Reverted the v0.8.8 two-poll confirmation heuristic for unexpected GREE states; unexpected states outside the known controller-command/baseline settling envelope are again acted on immediately. -- Added `device.remote_control_detected` diagnostics for poll-detected manual/pilot takeover, including raw/detected fields, before/after physical state, active controller settling commands/baselines and remaining settling-window time, plus current zone/Temporary Quick Thermostat ownership state. -- Retains the v0.8.8 narrow-screen Temporary Quick Thermostat modal layout fix. - -## 0.8.8 Poll-takeover confirmation and mobile modal fit - -- A single unexpected GREE poll snapshot inside the controller-command settling window no longer immediately becomes a physical/pilot takeover. The same unexpected physical state must persist into the next poll, so one transient mixed/out-of-order frame cannot break a scheduled Temporary Quick Thermostat start. -- Genuine direct/remote changes are still recognized after confirmation; deferred candidates are not lost when the next poll has no before/after delta. -- On narrow screens the Temporary Quick Thermostat **End and return to automation** button is placed below the active-session text and uses the full modal width. -- Rotated the PWA cache key so the corrected mobile stylesheet is fetched after upgrade. - -## 0.8.7 Temporary-session state cleanup - -- `activated_at` is now the only source of truth for Temporary Quick Thermostat ownership. -- Removed the legacy `generation=0` / `local_thermostat_power` activation fallback from the backend and Web UI. -- Removed the Temporary-session `generation` compatibility field and its legacy-only test path. -- Kept ordinary Quick Thermostat state only as an independent lower-priority owner restored after Temporary hand-back. - -## 0.8.6 Control-logic audit remediation - -- Separates ordinary Quick Thermostat handback state from Temporary Quick Thermostat lifecycle and makes fresh-session activation depend on `activated_at`. -- Hardens ownership priority so direct/manual takeover outranks temporary completion and pauses temporary hold/runtime accounting. -- Freezes active Temporary mode/target while deferring house/group climate changes until handback. -- Prevents unmanaged whole-house/group power-on, closes generic Settings/import bypasses, and safely stops devices before disable/detach. -- Makes thermostat-managed device automations durable zone-state changes instead of competing one-shot device frames. -- Adds schedule/time-automation deadlines to the common wakeup path and refreshes temporary schedule-boundary deadlines after schedule edits. -- Aligns Web UI/API lifecycle handling for active delayed/at sessions and exposes scheduled/waiting-master/paused-manual/active states. - -## 0.8.5 Temporary temperature-condition activation fix - -- Fixed `temperature_stable` / `temperature_reached` evaluation for sessions already marked active through `activated_at`. -- Kept delayed sessions protected from evaluating or completing before their scheduled start. -- Preserved migration compatibility with active v0.8.3 sessions by also accepting existing local Quick Thermostat ownership as an activation marker. -- Fixes the two v0.8.4 regression tests that stopped `scripts/update.sh` before service replacement. - -## 0.8.4 Scheduled Temporary Quick Thermostat start and ownership hardening - -- Temporary Quick Thermostat can start immediately, after a configurable delay, or at a specific local date/time. -- Delayed sessions do not take thermostat ownership early: normal schedules and automations continue until the effective start instant. -- Duration, safety timeout and next-schedule-boundary finish rules are calculated from the effective start, not from the time the session is configured. -- Active temporary ownership keeps its requested target across schedule transitions and schedule edits; direct device automations and group climate changes cannot overwrite it while it is active. -- A local Quick Thermostat can run using the zone's local Heat/Cool mode even when the inherited house climate mode is set to **Do not control**; the separate whole-house master power remains authoritative. -- Cancelling a not-yet-started session only removes that session and does not clear unrelated quick-control state. - -## 0.8.3 Temporary Quick Thermostat sessions - -- Adds a separate persisted timed Quick Thermostat session without reusing the existing 15-minute local-OFF hand-back timer. -- Supports ending after a duration, at a date/time, after reaching a temperature, after continuously holding a temperature condition, or at the next schedule boundary. -- Temperature conditions support within/at-or-below/at-or-above operators, tolerance and an optional safety timeout. -- Active sessions show their countdown/status directly on the Quick Thermostat card and restore the previous automation-enabled state when finished. -- Moves the zone automation enabled switch out of Quick Thermostat controls so automation availability and temporary thermostat ownership are visually separate. - -## 0.8.2 UI/i18n and Home Assistant sensor freshness - -- fixes ownership translations that were accidentally stored outside the language-pack `translations` object, -- adds a configurable Home Assistant sensor freshness limit in the Home Assistant / Sensors settings page (default 5 minutes), -- keeps legacy non-default per-zone stale limits as API-level overrides, -- prevents taller Quick Thermostat / Direct Device cards from stretching neighbouring dashboard cards, -- adds build-time validation for misplaced language-pack keys. - -## 0.8.0 Control ownership and thermostat safety - -- Adds an explicit zone control-ownership model (`control_owner`, command source, since/resume/reason) shared by the engine, API, Web UI and Home Assistant control plan. -- Distinguishes direct Web, direct Home Assistant and external/remote takeovers. Quick Thermostat commands explicitly return ownership to the thermostat. -- Adds per-zone operation mutexes plus an optional monotonic `revision` check for conflict-safe zone updates without breaking older clients that do not send a revision. -- Recomputes both thermostat override and physical-device manual takeover deadlines after schedule edits. -- Rejects stale Home Assistant temperature samples (default 300 seconds per zone) and falls back to the GREE sensor through the existing fallback path. -- Enforces automatic HVAC compressor protection: minimum OFF time before restart and an OFF delay when automatically changing Heat/Cool modes. Direct/manual commands and global safety OFF remain authoritative. -- Extends control-plan with desired vs actual HVAC state, ownership, block reason and lockout deadline. -- Shows the current control owner and timing in the Web dashboard. Home Assistant zone climate exposes the same diagnostics; the physical climate is named `Direct control` to make its semantics explicit. - -## 0.7.13 Quick-thermostat race protection and GREE frame counters - -- Quick thermostat actions from Web/Home Assistant are serialized with device polling, so a poll running at the same time cannot restore an older `device_manual_override` snapshot after the thermostat deliberately takes control back. -- Recent controller-originated climate transitions keep a bounded settling history of both requested and pre-command values. Delayed or out-of-order GREE status frames can pass through those known states without being misclassified as physical/pilot takeover. -- In Dashboard -> Quick thermostats, **Global** is disabled when the whole-house thermostat mode is **Do not control**. Heat/Cool remain available as explicit per-zone overrides. -- Settings now shows live UDP receive counters, both total and per configured air conditioner, since controller startup. Counters are independent of the debug-frame overlay and update over WebSocket. - -## 0.7.12 Timed quick-thermostat hand-back - -- Switching a single Quick Thermostat **OFF** now starts a 15-minute local-OFF window instead of leaving the zone outside automation indefinitely. -- The Web UI shows a live `MM:SS` countdown and a **Resume now** action during that window. -- At expiry, only local quick-thermostat ownership and its temporary quick profile/setpoint are cleared. The controller does not restore a stale physical ON/OFF state; it recalculates the current group, house mode, schedule, profile, room temperature and hysteresis. -- Every fresh local **OFF** action creates a new backend-owned 15-minute deadline; switching the local thermostat back **ON** cancels it. -- A physical/direct `device_manual_override` remains separate and temporarily suspends expiry of an existing local-OFF hand-back. Returning the unit to its previous OFF state re-arms a fresh 15-minute countdown from that moment. -- Persisted local-OFF state from 0.7.10/0.7.11 receives a fresh 15-minute hand-back deadline on the first controller cycle after upgrade. - -## 0.7.11 Cleanup and Rust test fix - -- Removed the obsolete `/api/zones/{id}/manual-power` compatibility endpoint and its handler; local thermostat power has one canonical API path: `/api/zones/{id}/control`. -- Renamed dashboard internals from `manual-power` terminology to `local-thermostat-power` to match the actual control model. -- Fixed the Rust ownership error in `local_thermostat_ownership_blocks_direct_automation` by borrowing the test zone via `std::slice::from_ref`, so the test no longer moves `zone` while borrowing `zone.device_id`. - -## 0.7.10 Local quick-thermostat ownership - -- Quick thermostat ON/OFF now controls a dedicated local thermostat state instead of using remote/manual-device semantics. -- A locally started zone bypasses its disabled group power gate but keeps full thermostat regulation: room sensor, hysteresis, profile/target, Smart Fan and GREE setpoint modulation. -- **Resume automation** clears local thermostat ownership and returns the zone to group/schedule control. -- Physical remote/direct device changes remain the only source of `device_manual_override`; explicit thermostat actions can take ownership back. -- Controller-originated GREE changes that appear after the device settling window are correlated with the pending controller command, preventing group ON and thermostat commands from falsely showing **Manual control / remote**. -- Home Assistant zone climate ON/OFF now uses the same local thermostat power semantics instead of technically disabling the zone. - -## 0.7.9 Zone profile control and Home Assistant stability - -- Quick thermostat profile/mode/target/enable actions now wake the thermostat regulator immediately instead of waiting for the next fixed zone interval. -- A manual/remote takeover keeps the configured thermostat profile target visible; the physical GREE target is reported separately as the device setpoint. This prevents standby setpoints such as 25 C from being shown as the Sleep/Comfort target. -- Control-plan `enabled` now represents the configured per-zone switch. A new `effective_enabled` field reports group gating, so Home Assistant zone switches/climate entities no longer bounce back to Off solely because a climate group is disabled. -- Home Assistant zone controls use a short pending-state guard, matching the existing physical-device anti-bounce protection. -- Every zone now exposes a **Work profile** select and the zone `climate` entity supports preset modes: Auto schedule, Comfort, Sleep and Away. - -## 0.7.8 Zone status wording and unit power - -- Zone runtime status now says **Zapotrzebowanie aktywne** when the thermostat is requesting work and **Cel osiągnięty** when the target/hysteresis condition is satisfied; **Komfort** remains only the preset name. Paused control is shown as **Oczekiwanie**, and missing control temperature as **Brak pomiaru**. -- Quick thermostats use one contextual **Włącz jednostkę / Wyłącz jednostkę** control instead of the redundant **Jednostka · pilot** caption and separate ON/OFF buttons. -- Direct unit power still behaves as manual/remote takeover and does not enable the whole group. +See [LICENSE](LICENSE). diff --git a/docs/API.md b/docs/API.md index 2063be8..01bd4d3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,93 +1,431 @@ -# API examples +# GREE Controller API reference -`TOKEN` is optional when `GREE_CONTROLLER_APP_TOKEN` is empty. +HTTP and WebSocket API for GREE Controller **0.8.13**. + +[← Main documentation](../README.md) + +## Base URL and content type + +Default local address: + +```text +http://127.0.0.1:8787 +``` + +JSON requests use: + +```text +Content-Type: application/json +``` + +If `GREE_CONTROLLER_BASE_PATH=/gree` is configured, every HTTP and WebSocket path below is prefixed with `/gree`. + +## Authentication + +There are three access levels. + +### Public + +No token is required for: + +```text +GET /api/health +GET / +GET /index.html +GET /app.js +GET /theme-init.js +GET /styles.css +GET /manifest.webmanifest +GET /sw.js +GET /favicon.svg +GET /lang/index.json +GET /lang/{file} +``` + +### Administrator API + +All normal `/api/*` routes are administrator routes. If `GREE_CONTROLLER_APP_TOKEN` is empty, the controller intentionally operates in trusted-LAN mode and these routes do not require authentication. + +When an app token is configured, send either: + +```text +Authorization: Bearer APP_TOKEN +``` + +or: + +```text +x-api-token: APP_TOKEN +``` + +Example: ```bash -AUTH='Authorization: Bearer TOKEN' BASE='http://127.0.0.1:8787' +TOKEN='replace-me' +curl -H "Authorization: Bearer $TOKEN" "$BASE/api/bootstrap" ``` +### Restricted Home Assistant API -## Home Assistant access tokens +Generated Home Assistant access tokens always authenticate only the restricted integration surface under `/api/integrations/home-assistant/*`. The administrator app token is also accepted there. -Create and revoke integration tokens from the controller Web UI under **More -> Home Assistant / Sensors**. The clear-text secret is returned only once and the SQLite database stores only its SHA-256 hash. +Generated token secrets are returned only at creation time. SQLite stores their SHA-256 hash and a display prefix. -Administrator endpoints: +## Errors and HTTP status codes -```text -GET /api/access-tokens -POST /api/access-tokens -DELETE /api/access-tokens/{id} +API errors are JSON: + +```json +{ + "error": "message" +} ``` -The Home Assistant custom integration uses a restricted API surface: +Common statuses: -```text -GET /api/integrations/home-assistant/devices -POST /api/integrations/home-assistant/devices/{id}/command -GET /api/integrations/home-assistant/control-plan -GET /api/integrations/home-assistant/groups -POST /api/integrations/home-assistant/groups/{id}/control -POST /api/integrations/home-assistant/house/control -POST /api/integrations/home-assistant/house/preset -POST /api/integrations/home-assistant/house/power -POST /api/integrations/home-assistant/zones/{id}/control +| Status | Meaning | +| --- | --- | +| `200 OK` | Successful read/update/action. | +| `201 Created` | Resource created. | +| `204 No Content` | Successful delete/revoke. | +| `400 Bad Request` | Validation error or unsafe/invalid operation. | +| `401 Unauthorized` | Missing/incorrect token. | +| `404 Not Found` | Resource ID does not exist. | +| `409 Conflict` | Revision/concurrency conflict. | +| `502 Bad Gateway` | GREE/HA/integration communication failure. | +| `500 Internal Server Error` | Unexpected server/storage error. | + +## Endpoint index + +### Public and system + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/api/health` | Lightweight process/control-engine health. | +| GET | `/api/bootstrap` | Complete initial application snapshot. | +| GET | `/api/system/info` | Runtime/system diagnostic information. | +| GET | `/ws` | Live WebSocket event stream. | + +### Devices and discovery + +| Method | Endpoint | Description | +| --- | --- | --- | +| POST | `/api/discovery` | Discover/bind GREE devices. | +| GET | `/api/devices` | List devices. | +| POST | `/api/devices` | Add a device manually. | +| GET | `/api/devices/{id}` | Read a device. | +| PATCH | `/api/devices/{id}` | Edit technical device configuration. | +| DELETE | `/api/devices/{id}` | Delete a device after safety checks. | +| POST | `/api/devices/{id}/bind` | Bind/re-bind a physical unit. | +| POST | `/api/devices/{id}/poll` | Poll one unit immediately. | +| POST | `/api/devices/{id}/command` | Send a direct/manual device command. | + +### Thermostat zones, groups and house + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/api/zones` | List zones. | +| POST | `/api/zones` | Create a zone. | +| GET | `/api/zones/{id}` | Read a zone. | +| PUT | `/api/zones/{id}` | Replace editable zone configuration. | +| DELETE | `/api/zones/{id}` | Delete zone after safe device shutdown. | +| POST | `/api/zones/{id}/control` | Quick/thermostat control of a zone. | +| POST | `/api/zones/{id}/schedule-template` | Replace zone schedules with a built-in template. | +| GET | `/api/groups` | List climate groups. | +| POST | `/api/groups` | Create a climate group. | +| GET | `/api/groups/{id}` | Read a group. | +| PUT | `/api/groups/{id}` | Replace group definition. | +| DELETE | `/api/groups/{id}` | Delete a group. | +| POST | `/api/groups/{id}/control` | Group power/mode/preset control. | +| POST | `/api/house/control` | Set global thermostat mode. | +| POST | `/api/house/power` | Set whole-house master power. | +| POST | `/api/house/preset` | Set/clear whole-house preset override. | + +### Schedules and automations + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/api/schedules` | List schedules. | +| POST | `/api/schedules` | Create schedule. | +| GET | `/api/schedules/{id}` | Read schedule. | +| PUT | `/api/schedules/{id}` | Replace schedule. | +| DELETE | `/api/schedules/{id}` | Delete schedule. | +| GET | `/api/automations` | List automations. | +| POST | `/api/automations` | Create automation. | +| GET | `/api/automations/{id}` | Read automation. | +| PUT | `/api/automations/{id}` | Replace automation. | +| DELETE | `/api/automations/{id}` | Delete automation. | + +### History, control plan and events + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/api/readings` | Legacy/device reading history. | +| GET | `/api/history` | Rich device/zone/HA history. | +| GET | `/api/control-plan` | Current resolved thermostat plan. | +| GET | `/api/events` | Event/debug log. | +| GET | `/api/events/retention` | Current event retention. | +| PUT | `/api/events/retention` | Update retention and prune immediately. | + +### Settings, backup and diagnostics + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/api/settings` | Public-safe runtime settings. | +| PUT | `/api/settings` | Update runtime settings. | +| GET | `/api/settings/export` | Export full application configuration. | +| POST | `/api/settings/import` | Import/replace application configuration. | +| GET | `/api/debug` | Read debug overlay settings. | +| PUT | `/api/debug` | Update debug overlay settings. | +| POST | `/api/integrations/home-assistant/test` | Test HA temperature read. | +| POST | `/api/integrations/notifications/test` | Send a test notification. | + +### Access tokens and restricted Home Assistant API + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/api/access-tokens` | List generated HA tokens without secrets. | +| POST | `/api/access-tokens` | Create restricted HA token. | +| DELETE | `/api/access-tokens/{id}` | Revoke token. | +| GET | `/api/integrations/home-assistant/devices` | Restricted device list. | +| POST | `/api/integrations/home-assistant/devices/{id}/command` | Restricted direct device command. | +| GET | `/api/integrations/home-assistant/control-plan` | Restricted control plan. | +| GET | `/api/integrations/home-assistant/groups` | HA-oriented group state. | +| POST | `/api/integrations/home-assistant/groups/{id}/control` | Restricted group control. | +| POST | `/api/integrations/home-assistant/house/control` | Restricted house mode. | +| POST | `/api/integrations/home-assistant/house/preset` | Restricted house preset. | +| POST | `/api/integrations/home-assistant/house/power` | Restricted master power. | +| POST | `/api/integrations/home-assistant/zones/{id}/control` | Restricted thermostat-zone control. | + +--- + +## System endpoints + +### `GET /api/health` + +Public lightweight health check. + +Response: + +```json +{ + "status": "ok", + "name": "gree-controller", + "version": "0.8.13", + "uptime_seconds": 1234, + "control_ready": true, + "time": "2026-08-30T06:54:00Z" +} ``` -These endpoints always require `Authorization: Bearer ` (or the administrator `GREE_CONTROLLER_APP_TOKEN`). A generated HA token cannot update settings, run discovery, delete devices, manage tokens, or use the controller WebSocket. +`control_ready=false` means the process is running but the thermostat engine has not yet completed its initial physical device synchronization. -## Discovery +### `GET /api/bootstrap` -```bash -curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"timeout_ms":6000,"broadcast":"255.255.255.255:7000","protocol_version":0,"passes":3}' +Returns the initial Web UI snapshot: + +```json +{ + "devices": [], + "zones": [], + "groups": [], + "schedules": [], + "automations": [], + "access_tokens": [], + "settings": {}, + "outdoor_temperature": null, + "system": { + "version": "0.8.13", + "uptime_seconds": 1234, + "auth_required": false, + "control_ready": true, + "database": "./data/gree-controller.db", + "device_count": 2, + "online_count": 2, + "simulator_count": 0, + "bind": "0.0.0.0:8787", + "base_path": "/", + "gree_interface": "auto", + "gree_received_frames": 809, + "gree_received_frames_by_device": {} + } +} ``` -`protocol_version` is `0` for auto/both, `1` for AES-ECB only, and `2` for AES-GCM only. `passes` is `1..10`. Auto is recommended when different GREE Wi-Fi module generations share the network. +### `GET /api/system/info` -## Device command +Returns the `system` diagnostic object independently of the full bootstrap. Useful for monitoring and **Settings → System status**. + +--- + +## Device API + +### Device object + +A device response contains: + +| Field | Type | Description | +| --- | --- | --- | +| `id` | string | Stable controller ID. | +| `mac` | string | Normalized GREE MAC/CID identity. | +| `name` | string | User-visible name. | +| `ip` | string | Device IPv4 address. | +| `port` | integer | Usually `7000`. | +| `protocol_version` | integer | `0` unknown/auto, `1` legacy AES-ECB, `2` AES-GCM. | +| `model`, `firmware` | string | Discovered metadata when available. | +| `key` | string/null | GREE binding key. Treat as secret. | +| `cid` | string/null | GREE client/device identifier. | +| `enabled` | boolean | Technical device enable state. | +| `simulated` | boolean | Simulated vs physical. | +| `power` | boolean | Last known power. | +| `mode` | string | `auto`, `cool`, `dry`, `fan`, `heat`. | +| `target_temperature` | number | Last known unit setpoint. | +| `fan_speed` | integer | `0..5`; `0` is Auto. | +| `swing_vertical`, `swing_horizontal` | boolean | Swing state. | +| `quiet`, `turbo`, `light`, `air`, `xfan`, `health`, `sleep` | boolean | Optional GREE features. | +| `supports_*` | boolean/null | Capability learned from device status. | +| `current_temperature` | number/null | GREE indoor temperature. | +| `outdoor_temperature` | number/null | GREE outdoor temperature if available. | +| `temperature_sensor_offset` | boolean/null | Whether +40 °C wire offset behavior was detected. | +| `online` | boolean | Current communication state. | +| `response_time_ms` | integer/null | Latest successful controller round-trip. | +| `last_seen` | ISO-8601/null | Last successful communication. | +| `last_error` | string/null | Latest communication error. | +| `communication_failures` | integer | Consecutive/recorded communication failure counter. | +| `created_at`, `updated_at` | ISO-8601 | Resource timestamps. | + +### `POST /api/discovery` + +Request body, all fields optional: + +```json +{ + "timeout_ms": 6000, + "broadcast": "255.255.255.255:7000", + "protocol_version": 0, + "passes": 3 +} +``` + +Rules: + +- `timeout_ms`: effective range `500..30000` ms. +- `protocol_version`: `0` auto/both, `1` AES-ECB only, `2` AES-GCM only. +- `passes`: `1..10`. +- Missing values use runtime GREE settings. + +Successful discovery merges known devices, tries binding devices that do not have a key, persists results and returns: + +```json +{ + "count": 1, + "devices": [], + "new_device_ids": ["gree-aabbccddeeff"] +} +``` + +### `GET /api/devices` + +Returns `Device[]`. + +### `POST /api/devices` + +Manual add request: + +```json +{ + "name": "Living room", + "mac": "AABBCCDDEEFF", + "ip": "192.168.50.30", + "port": 7000, + "protocol_version": 1, + "key": null, + "simulated": false +} +``` + +Defaults: `port=7000`, `protocol_version=1`, `simulated=false`. MAC values are normalized. Duplicate MACs are rejected. + +Returns `201 Created` with `Device`. + +### `GET /api/devices/{id}` + +Returns one `Device` or `404`. + +### `PATCH /api/devices/{id}` + +All fields optional: + +```json +{ + "name": "Bedroom", + "ip": "192.168.50.31", + "port": 7000, + "protocol_version": 2, + "key": "optional-binding-key", + "enabled": true +} +``` + +`key:null` clears the key. Changing protocol version clears the existing key/capability cache so the unit can be re-bound cleanly. Disabling a device goes through the controller's safe disable path. + +### `DELETE /api/devices/{id}` + +Returns `204`. Deletion is rejected if the device is referenced by an automation or cannot be safely detached from thermostat ownership. Associated zones/groups are cleaned only after the safety checks pass. + +### `POST /api/devices/{id}/bind` + +Performs/repeats GREE binding and returns updated `Device`. Simulated devices return unchanged. + +### `POST /api/devices/{id}/poll` + +Immediately polls one unit and returns updated `Device`. + +### `POST /api/devices/{id}/command` + +Direct/manual device control. This is deliberately different from thermostat-zone control. + +All fields optional; at least one meaningful field should be sent: ```json { "power": true, "mode": "cool", - "target_temperature": 22.0, + "target_temperature": 22, "fan_speed": 3, "swing_vertical": true, + "swing_horizontal": false, "quiet": false, "turbo": false, "light": true, - "xfan": false, "air": false, + "xfan": false, "health": false, "sleep": false } ``` -Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Optional properties map to GREE `Blo` (X-FAN), `Air`, `Health` and `SwhSlp` (Sleep) and should only be used when the device reports the matching `supports_*` capability. Physical GREE Celsius setpoints are normalized to whole degrees in the `8..30°C` range. +Rules: -## Smart thermostat / house control +- modes: `auto`, `cool`, `dry`, `fan`, `heat`, +- target temperature is normalized to the supported GREE Celsius range `8..30`, +- fan speed is `0..5`, +- optional feature commands should be used only when the corresponding `supports_*` capability is true. -The controller separates the seasonal house mode from per-zone profiles. Normal operation uses setpoint modulation: units remain powered while the controller moves the device target between an active and satisfied setpoint. House mode `off` means **do not control** inherited zones: it does not power a manually operated unit down. A zone explicitly switched to `cool` or `heat` remains independent and can continue to run. +The backend sends only properties that differ from the last known device state. Climate-relevant direct commands can create/continue a manual-device takeover for an enabled thermostat zone so automation does not immediately fight the user. -```bash -curl -X POST "$BASE/api/house/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"mode":"cool"}' +--- -curl -X POST "$BASE/api/house/power" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"power":false}' +## Zones -curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"preset":"sleep"}' -``` +### Zone configuration -House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on, all group power gates are enabled and only devices assigned to enabled thermostat zones that are not in manual-device override are powered on. `power:false` is authoritative: it clears manual-device overrides and local quick-thermostat power ownership, disables every group and powers off every enabled physical device. Disabled zones otherwise remain outside house/group/automation control and their underlying unit can be operated manually through technical device control or its physical remote. Choosing house mode `off` does not change master power. - -A zone stores separate profile temperatures for both seasons: +`POST /api/zones` and `PUT /api/zones/{id}` use this editable shape: ```json { - "name": "Paweł", + "name": "Living room", "device_id": "gree-aabbccddeeff", "enabled": true, "mode": "cool", @@ -100,232 +438,1034 @@ A zone stores separate profile temperatures for both seasons: "heat_sleep_setpoint": 19.0, "heat_away_setpoint": 17.0, "hysteresis": 0.6, + "min_on_seconds": 180, + "min_off_seconds": 180, "min_adjust_seconds": 120, "standby_offset_c": 2.0, "smart_fan": true, "sensor_source": "combined", - "ha_entity_id": "sensor.pawel_temperature", + "ha_entity_id": "sensor.living_room_temperature", "external_sensor_weight": 0.4, - "max_sensor_difference": 3.0 + "max_sensor_difference": 3.0, + "sensor_stale_after_seconds": 300, + "revision": 12 } ``` -Quick control does not directly fight the schedule engine: +Important rules: + +- `name` is required. +- `device_id` must reference an existing device and thermostat ownership must remain valid/safe. +- `mode`: `cool` or `heat` when not inheriting house mode. +- temperature/profile values are constrained to the supported thermostat range. +- `hysteresis`: controller-valid range is approximately `0.1..5.0` °C. +- `standby_offset_c`: bounded thermostat offset. +- `sensor_source`: `device`, `home_assistant` or `combined`. +- `external_sensor_weight`: `0..1`. +- `revision` is used for optimistic concurrency where supplied; stale updates can return `409`. + +The returned `Zone` also contains runtime state including sensor readings, resolved/effective setpoints, demand, current preset, manual overrides, local Quick Thermostat ownership, temporary session state, device-manual takeover, control owner/source/reason, lockout timestamps and `created_at`/`updated_at`. + +### `GET /api/zones` + +Returns `Zone[]`. + +### `POST /api/zones` + +Creates a zone and returns `201 Created` with `Zone`. + +### `GET /api/zones/{id}` + +Returns one `Zone`. + +### `PUT /api/zones/{id}` + +Replaces editable zone configuration while preserving/reconciling runtime safety state. Returns updated `Zone`. + +### `DELETE /api/zones/{id}` + +Safely powers the owned device off before detaching thermostat ownership. Returns `204`. + +### `POST /api/zones/{id}/control` + +Quick thermostat endpoint. Body fields are optional and can be combined: + +```json +{ + "setpoint": 22.5, + "power": true, + "mode": "house", + "enabled": true, + "preset": "comfort", + "clear_override": false, + "clear_device_manual_override": false, + "clear_local_thermostat_override": false, + "temporary_quick_thermostat": null, + "clear_temporary_quick_thermostat": false +} +``` + +Semantics: + +- `setpoint`: creates a quick custom thermostat target. +- `preset`: `auto`, `comfort`, `sleep`, `away`, `custom`; `auto` clears the profile override. +- `mode`: `house`, `cool`, `heat`; `house` restores global mode inheritance. +- `enabled`: zone automation enable state. +- `power:true`: local Quick Thermostat ownership — this zone can run through full thermostat logic even if its climate group is off. +- `power:false`: turns this zone off and creates a fresh backend-owned local hand-back timer (currently 15 minutes). +- `clear_local_thermostat_override:true`: immediately return local Quick Thermostat ownership to normal group/schedule control. +- `clear_device_manual_override:true`: explicitly hand a physical/direct manual takeover back to the thermostat. +- `clear_override:true`: clear ordinary quick preset/setpoint override. +- `temporary_quick_thermostat`: start/replace a persisted temporary session. +- `clear_temporary_quick_thermostat:true`: cancel that temporary session only. + +Returns updated `Zone`. + +#### Temporary Quick Thermostat request + +```json +{ + "start_kind": "now", + "start_delay_minutes": null, + "start_at": null, + "finish_kind": "duration", + "duration_minutes": 90, + "until": null, + "target_temperature": 23.0, + "temperature_operator": "within", + "tolerance_c": 0.3, + "hold_minutes": 60, + "max_duration_minutes": 240 +} +``` + +`start_kind`: + +- `now` +- `delay` + `start_delay_minutes` +- `at` + ISO-8601 `start_at` + +`finish_kind`: + +- `duration` + `duration_minutes` +- `until` + ISO-8601 `until` +- `temperature_reached` +- `temperature_stable` + `hold_minutes` +- `schedule_boundary` + +Temperature operators: + +- `within` +- `at_or_below` +- `at_or_above` + +`max_duration_minutes` is an optional fail-safe for temperature-based sessions. Delayed sessions do not own the zone until their effective start. Runtime session state is persisted and exposed inside the returned zone. + +Examples: ```bash -# Sleep until the next schedule boundary (or until cleared if there is no future transition) +# Preset until next schedule boundary curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"preset":"sleep"}' -# Return to automatic schedule +# Return to schedule curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"preset":"auto"}' -# Temporary custom room target +# Run a 90-minute temporary thermostat curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"setpoint":22.5}' - -# Start this zone as a local thermostat. This does not enable its climate group, -# but the full thermostat logic (sensor/hysteresis/profile/GREE modulation) remains active. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"power":true}' - -# Return local thermostat power ownership to the group/schedule. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"clear_local_thermostat_override":true}' - -# Run Quick Thermostat for 90 minutes, then hand control back to automation. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"duration","duration_minutes":90,"target_temperature":23.0}}' - -# Schedule Quick Thermostat to start in 45 minutes and run for two hours. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"temporary_quick_thermostat":{"start_kind":"delay","start_delay_minutes":45,"finish_kind":"duration","duration_minutes":120,"target_temperature":23.0}}' - -# Schedule an absolute start. All timestamps are ISO-8601 and are stored as UTC internally. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"temporary_quick_thermostat":{"start_kind":"at","start_at":"2026-08-28T18:30:00+02:00","finish_kind":"until","until":"2026-08-28T22:00:00+02:00","target_temperature":23.0}}' - -# Hold 23 C continuously for one hour. Leaving the +/-0.3 C band resets the hold timer. -# The optional safety limit hands control back after four hours even if the condition never settles. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"temperature_stable","target_temperature":23.0,"temperature_operator":"within","tolerance_c":0.3,"hold_minutes":60,"max_duration_minutes":240}}' - -# Cancel only the temporary Quick Thermostat session and return to the previous automation state. -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"clear_temporary_quick_thermostat":true}' + -d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"duration","duration_minutes":90,"target_temperature":23}}' ``` -`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones. +### `POST /api/zones/{id}/schedule-template` -`power:true/false` on `/api/zones/{id}/control` is a local quick-thermostat power override, not a physical/manual command. `true` lets that zone run through the full thermostat even when its climate group is off and cancels any pending local hand-back timer. Every accepted `false` action turns only that zone off immediately and creates a fresh backend-owned `local_thermostat_resume_at` 15 minutes in the future. When that deadline is reached, the controller clears local quick ownership plus its temporary quick preset/setpoint and recalculates the current group/schedule/house-mode state instead of restoring an old physical ON/OFF snapshot. `clear_local_thermostat_override:true` performs that hand-back immediately. Direct/pilot-style control remains `/api/devices/{id}/command`. - -`temporary_quick_thermostat` is a separate persisted session and does **not** reuse `local_thermostat_resume_at`. `start_kind` accepts `now`, `delay`, or `at`; delayed sessions keep normal schedules and automations active until `started_at`, then acquire local thermostat ownership. Supported `finish_kind` values are `duration`, `until`, `temperature_reached`, `temperature_stable`, and `schedule_boundary`. Durations and safety limits begin at the effective start, not when a delayed session is created. `schedule_boundary` resolves the first schedule transition after the effective start. Temperature rules support `within`, `at_or_below`, and `at_or_above`, a tolerance, an optional safety deadline, and for `temperature_stable` a continuous hold duration. While active, the temporary target has priority over schedule transitions and normal device/group automations; those sources resume after hand-back. If the zone automation was disabled before the session, the session temporarily enables thermostat control and restores the disabled state when it finishes. - -### Physical/manual device takeover - -The poller compares climate-relevant unit state with the last controller-known state. An external change of power, HVAC mode, target temperature or fan speed (for example from the IR remote) sets `device_manual_override=true` on the assigned zone. Controller-originated thermostat/group commands are correlated with a short-lived expected-state record when GREE status has not settled yet, so their delayed status transition is not mistaken for an external takeover. During that settling window, one otherwise-unexpected poll snapshot is treated as transient and must persist into the following poll before manual takeover is declared. The zone continues sensor/history updates but thermostat modulation, schedules, groups and direct-device automations stop issuing corrective climate commands. The controller stores the pre-takeover climate state; if the user later returns the unit to that operational state, the takeover is cleared automatically and the stale **Resume automation** prompt disappears. When the pre-takeover state was OFF, switching the unit OFF again is sufficient even if the remote kept a different dormant target/mode internally. If that OFF state belongs to an active local quick-thermostat hand-back, its countdown is suspended while direct/manual takeover is active and is re-armed for a fresh 15 minutes when the unit returns to OFF. Otherwise the manual-device override expires at the next schedule transition when one exists; without a future transition it stays active until explicitly resumed. The known GREE standby normalization from Low fan back to Auto is ignored so it does not create a false takeover. - -The technical `POST /api/devices/{id}/command` path uses the same takeover semantics for climate-relevant changes; light-only and unrelated feature changes do not suspend the thermostat. Local quick-thermostat power is deliberately separate from this state. An explicit quick-zone action (local power, target, mode, preset or enabled state) resumes thermostat ownership from a physical/direct takeover automatically. It can also be resumed directly: - -```bash -curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"clear_device_manual_override":true}' -``` - -Whole-house `power:false` is the deliberate exception and always clears both physical/manual takeover and local quick-thermostat ownership before powering units off. Group power commands do not alter the physical unit while that zone has an explicit local thermostat power override, and group/thermostat commands do not fight a zone while its physical/manual-device override is active. - -### Climate groups - -Groups persist a set of thermostat zone IDs and an independent group power gate. Group control reuses the normal zone engine, so profiles, schedules, hysteresis and sensor selection remain unchanged. - -```bash -curl -X POST "$BASE/api/groups" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"name":"Upstairs","zone_ids":["ZONE_A","ZONE_B"]}' - -curl -X POST "$BASE/api/groups/GROUP_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"mode":"cool","preset":"sleep","power":true}' -``` - -Group `mode` accepts `house`/`auto` (follow whole-house Heating/Cooling), `cool`, or `heat`. Group `preset` accepts `auto`, `comfort`, `sleep`, or `away`. Automations may set `action_group_id` and optional `action_preset`; direct-device `action_device_id` remains backward compatible. - -### Ready-made schedule templates - -Templates generate normal schedule records and replace the current schedules for the selected zone. Every generated entry remains editable. - -```bash -curl -X POST "$BASE/api/zones/ZONE_ID/schedule-template" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"template":"child"}' -``` - -Templates: `family`, `child`, `bedroom`, `workday`, `always`. - -Schedules use a profile (`comfort`, `sleep`, `away`) or a custom temperature: +Body: ```json { - "zone_id": "UUID", - "name": "Sleep", - "enabled": true, - "weekdays": [1,2,3,4,5,6,7], - "start_time": "20:30", - "end_time": "06:30", - "preset": "sleep", - "setpoint": 24.0 + "template": "family" } ``` -For non-`custom` schedules, `setpoint` is only a compatibility value; the actual target comes from the zone's seasonal profile. `start_time == end_time` means a 24-hour block for each selected weekday. Enabled schedules belonging to the same zone are rejected when their active windows overlap, including overnight overlaps. +Built-in templates: -### Home Assistant outdoor assist and local HTTPS +| Template | Result | +| --- | --- | +| `family` | Comfort `06:30–22:30`, Sleep overnight. | +| `child` | Comfort `06:30–20:30`, Sleep overnight. | +| `bedroom` | Comfort `06:30–22:00`, Sleep overnight. | +| `workday` | Weekday morning/away/evening/sleep plus weekend blocks. | +| `always` | 24-hour Comfort. | -Runtime settings can specify `home_assistant.outdoor_entity_id` and `outdoor_assist_enabled`. Outdoor temperature never replaces the zone room temperature. It is only used to make active setpoints/fan speed slightly more assertive in extreme weather. - -`home_assistant.sensor_stale_after_seconds` controls the maximum accepted age of Home Assistant temperature samples. The default is 300 seconds (5 minutes) and the backend clamps the value to 30-86400 seconds. The Web UI exposes this value in minutes under **Home Assistant / Sensors**. Existing zones keep compatibility with the historical `zone.sensor_stale_after_seconds` field: the old hidden/default value of 300 seconds inherits the global HA setting, while a non-default value supplied through the zone API remains a per-zone override. - -For trusted local Home Assistant servers with self-signed/invalid HTTPS certificates, set: +Existing schedules for the zone are replaced after overlap validation. Response: ```json { - "home_assistant": { - "url": "https://10.87.65.2", - "allow_invalid_tls": true + "zone": {}, + "schedules": [] +} +``` + +--- + +## Groups + +### Group object + +```json +{ + "id": "uuid", + "name": "Bedrooms", + "zone_ids": ["zone-1", "zone-2"], + "power_enabled": true, + "created_at": "...", + "updated_at": "..." +} +``` + +### `GET /api/groups` + +Returns groups. + +### `POST /api/groups` + +```json +{ + "name": "Bedrooms", + "zone_ids": ["zone-1", "zone-2"], + "power_enabled": true +} +``` + +A group must contain at least one existing zone. Returns `201 Created`. + +### `GET /api/groups/{id}` / `PUT /api/groups/{id}` / `DELETE /api/groups/{id}` + +Read, replace or delete a group. `DELETE` returns `204`. + +### `POST /api/groups/{id}/control` + +```json +{ + "power": true, + "mode": "house", + "preset": "comfort" +} +``` + +All fields optional: + +- `power`: group gate, +- `mode`: `house`, `cool`, `heat`, +- `preset`: `auto`, `comfort`, `sleep`, `away`. + +Returns a group control/result object including updated members/state. + +--- + +## Whole-house control + +House thermostat mode and master power are intentionally separate. + +### `POST /api/house/control` + +```json +{ + "mode": "cool" +} +``` + +Valid modes: `cool`, `heat`, `off`. + +- `cool`/`heat` are explicit whole-house activation requests: master power is enabled, group gates are enabled and the thermostat arbiter starts eligible managed zones. +- `off` means **do not perform house-level thermostat control** for inherited zones. It does not itself change master power and does not forcibly power direct/manual devices down. + +Returns public runtime settings. + +### `POST /api/house/power` + +```json +{ + "power": false +} +``` + +`false` is authoritative: it disables group gates, clears local/manual ownership markers as required and powers every enabled physical device down. `true` resumes thermostat arbitration rather than blindly sending a bare ON frame. + +Response includes: + +```json +{ + "power": false, + "devices": [], + "groups": [], + "settings": {}, + "failed": [] +} +``` + +### `POST /api/house/preset` + +```json +{ + "preset": "sleep" +} +``` + +Valid: `auto`, `comfort`, `sleep`, `away`. + +A non-`auto` preset creates zone overrides that normally expire at each zone's next schedule boundary. `auto` clears them. Selecting a house preset also explicitly re-enables master power/group gates. + +--- + +## Schedules + +Schedule object/request: + +```json +{ + "zone_id": "zone-1", + "name": "Night", + "enabled": true, + "weekdays": [1, 2, 3, 4, 5, 6, 7], + "start_time": "22:30", + "end_time": "06:30", + "preset": "sleep", + "setpoint": 24.5 +} +``` + +Rules: + +- weekdays use ISO numbers `1=Monday ... 7=Sunday`, +- times use local `HH:MM`, +- crossing midnight is supported, +- `start_time == end_time` represents a 24-hour window for selected weekdays, +- preset: `comfort`, `sleep`, `away`, `custom`, +- custom setpoint: `8..30` °C, +- enabled schedules for the same zone cannot overlap. + +For non-custom presets, the effective target comes from the zone's seasonal profile; `setpoint` is retained for compatibility. + +Routes: + +```text +GET /api/schedules +POST /api/schedules +GET /api/schedules/{id} +PUT /api/schedules/{id} +DELETE /api/schedules/{id} +``` + +Create returns `201`; delete returns `204`. + +--- + +## Automations + +Automation request/object fields: + +```json +{ + "name": "Hot room", + "enabled": true, + "trigger_kind": "temperature_above", + "trigger_device_id": "gree-aabbccddeeff", + "threshold": 27.0, + "at_time": null, + "action_device_id": "gree-aabbccddeeff", + "action_group_id": null, + "action_preset": null, + "action": { + "power": true, + "mode": "cool", + "target_temperature": 23 + }, + "cooldown_seconds": 300 +} +``` + +Triggers: + +- `temperature_above`: requires `trigger_device_id` + `threshold`, +- `temperature_below`: requires `trigger_device_id` + `threshold`, +- `time`: requires local `at_time` in `HH:MM`. + +Action target is either: + +- direct device: `action_device_id` + full `DeviceCommand`, or +- group: `action_group_id`; group automation supports only power, `house`/`cool`/`heat` mode and optional `action_preset` (`auto|comfort|sleep|away`). + +The response also contains runtime `last_fired_at`, `created_at`, `updated_at`. + +Routes: + +```text +GET /api/automations +POST /api/automations +GET /api/automations/{id} +PUT /api/automations/{id} +DELETE /api/automations/{id} +``` + +Create returns `201`; delete returns `204`. + +--- + +## History and readings + +### `GET /api/readings` + +Legacy/lightweight device history. + +Query parameters: + +| Parameter | Default | Description | +| --- | --- | --- | +| `device_id` | all | Optional device filter. | +| `hours` | `24` | Clamped to `1..87600` (10 years). | +| `limit` | `1500` | Row limit. | + +Response: + +```json +{ + "readings": [ + { + "id": 1, + "device_id": "gree-aabbccddeeff", + "timestamp": "...", + "indoor_temperature": 23.4, + "outdoor_temperature": 30.1, + "target_temperature": 23, + "power": true, + "source": "poll" + } + ] +} +``` + +### `GET /api/history` + +Rich chart/history API. + +Query parameters: + +| Parameter | Description | +| --- | --- | +| `scope` | `overview`, `zones`/`zone`, `devices`, `sensors`; default `zones`. | +| `zone_id` | Zone filter for zone scope. | +| `device_id` | Device filter for device scope. | +| `entity_id` | Home Assistant entity filter for sensor scope. | +| `hours` | Default `24`, clamped to 10 years. | +| `limit` | Default `12000`, clamped to `1..20000`. | + +Bucket resolution: + +| Range | Bucket | +| --- | --- | +| ≤ 6 h | 30 s | +| ≤ 24 h | 2 min | +| ≤ 7 d | 10 min | +| ≤ 30 d | 30 min | +| ≤ 90 d | 2 h | +| ≤ 1 y | 6 h | +| > 1 y | 24 h | + +Zone reading fields: + +```text +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 +``` + +Device reading fields are the `Reading` fields documented above. HA sensor rows contain: + +```text +id, entity_id, zone_id, kind, timestamp, temperature +``` + +`scope=overview` returns all three families plus counts and per-family storage source. + +When InfluxDB is enabled, older history can be read from Influx and merged with recent SQLite rows. A failed Influx query falls back to available SQLite data and reports `storage_warning` rather than failing the entire chart response. + +--- + +## Control plan + +### `GET /api/control-plan` + +Returns the resolved machine-readable thermostat plan: + +Top-level fields: + +```text +generated_at +house_mode +house_preset +house_power +outdoor_temperature +control_strategy +night_mode_active +night_mode_start +night_mode_end +night_mode_max_fan_speed +next_events[] +zones[] +rules[] +``` + +Each zone plan includes: + +```text +zone_id, zone_name, device_id, device_name, +enabled, effective_enabled, +mode, configured_mode, inherit_house_mode, +preset, preset_override, +current_temperature, target_temperature, device_setpoint, +desired_power, desired_mode, +actual_power, actual_mode, actual_setpoint, +demand, control_source, +manual_override_until, +local_thermostat_power, local_thermostat_resume_at, +device_manual_override, device_manual_override_until, +control_owner, control_command_source, control_since, resume_at, control_reason, +blocked_reason, lockout_until, +current_schedule_id, current_schedule_name, +next_events[] +``` + +This endpoint is the best way for another client to understand **desired vs actual state**, who owns control, and why a zone is blocked/paused. + +--- + +## Events and retention + +### `GET /api/events?limit=100` + +Response: + +```json +{ + "events": [ + { + "id": 1, + "timestamp": "...", + "level": "info", + "kind": "device.updated", + "message": "...", + "metadata": {} + } + ] +} +``` + +### `GET /api/events/retention` + +```json +{ + "days": 30 +} +``` + +### `PUT /api/events/retention` + +```json +{ + "days": 30 +} +``` + +Value is clamped to `1..3650`; pruning happens immediately. Response includes `days` and number of removed rows. + +--- + +## Runtime settings + +### `GET /api/settings` + +Returns a public-safe settings document. Secrets are blanked and accompanied by `*_configured` booleans where relevant. + +Shape: + +```json +{ + "controller_id": "gree-controller", + "simulator_enabled": false, + "poll_interval_seconds": 15, + "zone_interval_seconds": 5, + "discovery_timeout_ms": 3000, + "discovery_broadcast": "255.255.255.255:7000", + "house_mode": "cool", + "house_power_enabled": true, + "control_strategy": "setpoint", + "outdoor_assist_enabled": true, + "history_retention_days": 30, + "history_compaction_enabled": true, + "event_log_retention_days": 30, + "suppress_device_beep": false, + "debug": { + "overlay_enabled": false, + "gree_frames": false + }, + "night_mode": { + "enabled": false, + "start_time": "22:00", + "end_time": "06:00", + "max_fan_speed": 1, + "force_quiet": true, + "use_native_sleep": true + }, + "notifications": {}, + "influxdb": {}, + "home_assistant": {} +} +``` + +#### Home Assistant settings + +```json +{ + "url": "http://homeassistant.local:8123", + "token": "", + "token_configured": true, + "default_entity_id": "sensor.room_temperature", + "outdoor_entity_id": "sensor.outdoor_temperature", + "sensor_stale_after_seconds": 300, + "allow_invalid_tls": false, + "sensor_aliases": { + "sensor.room_temperature": "Living room" } } ``` -This setting is opt-in and applies only to the controller's outbound Home Assistant sensor client. +#### InfluxDB settings -## Current control plan +```json +{ + "enabled": true, + "version": "2", + "url": "http://influxdb:8086", + "database": "gree_controller", + "username": "", + "password": "", + "password_configured": false, + "org": "home", + "bucket": "gree_controller", + "token": "", + "token_configured": true, + "history_threshold_days": 30 +} +``` -When cooling is driven by a valid per-zone Home Assistant room sensor (`external` or `combined` control source), active demand forces the next lower whole-degree unit target (typically 0.5-1.0 C below the room target). It is not stacked on top of a larger outdoor-assist correction, and it is disabled automatically whenever control falls back to the GREE sensor. This helps the independent room sensor reach the requested temperature without increasing overcooling risk during sensor failures. +Version `1` uses database/optional username/password. Version `2` uses org/bucket/token. -On process startup the thermostat/schedule/automation engine stays command-passive until one full live poll of all enabled GREE units completes. Persisted device/zone/group state is still available immediately for the UI, but it is not used to emit climate commands before the physical state has been synchronized. +#### Notification settings -`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, current schedule, manual schedule override expiry, local quick-thermostat power ownership and automatic resume deadline, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. During physical/manual takeover, `target_temperature` remains the thermostat profile/schedule target while `device_setpoint` reports the actual GREE target; automation remains paused until resumed or another explicit zone control is sent. +```json +{ + "enabled": true, + "mode": "problems", + "provider": "pushover", + "pushover_app_token": "", + "pushover_user_key": "", + "pushover_configured": true, + "slack_webhook_url": "", + "slack_configured": false, + "discord_webhook_url": "", + "discord_configured": false, + "cooldown_seconds": 300, + "communication_failure_threshold": 3, + "target_timeout_minutes": 60, + "alert_types": { + "stale_sensor": true, + "sensor_errors": true, + "communication": true, + "target_timeout": true, + "automation": true, + "control_errors": true, + "important_events": true, + "other": true + } +} +``` + +Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`. + +### `PUT /api/settings` + +Accepts the complete `RuntimeSettings` document. Important behavior: + +- `house_mode` and `house_power_enabled` cannot be changed here; use House Control API. +- polling interval is clamped `2..3600` seconds. +- zone interval is clamped `2..3600` seconds. +- discovery timeout is clamped `300..30000` ms. +- `control_strategy` is normalized to `setpoint`. +- discovery broadcast must be `auto`, `auto:*` or a valid socket address. +- blank HA token preserves the saved token. +- blank Influx token/password preserve saved secrets. +- blank Pushover/Slack/Discord secret fields preserve saved secrets. +- HA sensor age is clamped `30..86400` seconds. +- notification cooldown: `30..86400` seconds. +- communication failure threshold: `2..100`. +- target timeout: `5..1440` minutes. +- retention windows: `1..3650` days. +- night mode times must be `HH:MM`; max fan is clamped `1..5`. + +Returns the safe public settings form. + +### `GET /api/settings/export` + +Returns configuration format version `1`: + +```text +format_version +exported_at +settings +devices[] +zones[] +groups[] +schedules[] +automations[] +``` + +Export includes GREE binding keys and integration credentials. It excludes metric history, event rows and generated API-token records. Treat the export as a secret. + +### `POST /api/settings/import` + +Accepts exactly the export document. The backend validates IDs/references/schedules/settings, safely stops devices whose ownership is being removed, clears transient ownership/timers/stale live state, replaces configuration, re-polls devices, then re-enables thermostat control. + +Metric/event history and generated access-token records are preserved. + +Response: + +```json +{ + "ok": true +} +``` + +--- + +## Debug API + +### `GET /api/debug` + +```json +{ + "overlay_enabled": true, + "gree_frames": true +} +``` + +### `PUT /api/debug` + +Accepts the same object, persists it and broadcasts `debug.settings`. + +When overlay diagnostics are enabled, live HTTP requests generate `api.request` WebSocket events containing method, path, status and duration. When `gree_frames=true`, sanitized GREE protocol events are also sent as `gree.frame`. + +The Web UI can display **All**, **Requests** or **GREE** subsets. + +--- + +## Integration tests + +### `POST /api/integrations/home-assistant/test` + +```json +{ + "entity_id": "sensor.room_temperature" +} +``` + +`entity_id` is optional; controller defaults/aliases are resolved. Response: + +```json +{ + "ok": true, + "temperature_c": 23.4, + "entity_id": "sensor.room_temperature" +} +``` + +### `POST /api/integrations/notifications/test` + +Accepts a `NotificationSettings` object. Blank secret/webhook fields reuse saved secrets for the test. + +Response: + +```json +{ + "ok": true +} +``` + +--- + +## Access tokens + +### `GET /api/access-tokens` + +Returns: + +```json +[ + { + "id": "uuid", + "name": "Home Assistant", + "token_prefix": "gree_controller_abc...", + "created_at": "..." + } +] +``` + +### `POST /api/access-tokens` + +```json +{ + "name": "Home Assistant" +} +``` + +Name length: `1..80`. If omitted, default is `Home Assistant`. + +Response `201 Created`: + +```json +{ + "token": "gree_controller_FULL_SECRET_SHOWN_ONCE", + "item": { + "id": "uuid", + "name": "Home Assistant", + "token_prefix": "gree_controller_...", + "created_at": "..." + } +} +``` + +### `DELETE /api/access-tokens/{id}` + +Revokes token and returns `204`. + +--- + +## Restricted Home Assistant API + +These routes always require a generated token or the administrator app token. + +### `GET /api/integrations/home-assistant/devices` + +Returns `Device[]`. + +### `POST /api/integrations/home-assistant/devices/{id}/command` + +Accepts `DeviceCommand`. A direct command is rejected if the device belongs to a disabled thermostat zone; re-enable the zone for normal HA/controller ownership or use the administrator technical device endpoint deliberately. + +### `GET /api/integrations/home-assistant/control-plan` + +Same payload as administrator `GET /api/control-plan`. + +### `GET /api/integrations/home-assistant/groups` + +Returns an HA-oriented derived group list. Each object includes: + +```text +id, name, zone_ids, zone_names, +power_enabled, effective_power, +mode, preset, house_mode, +zone_count, enabled_zones, active_zones, demanding_zones, +device_count, online_devices, current_temperature, +members[], next_events[] +``` + +Member rows include zone/device identity, configured/effective enable state, mode/preset, room/target temperature, demand, source, schedule and current manual/local ownership markers. + +### `POST /api/integrations/home-assistant/groups/{id}/control` + +Same body/semantics as normal group control. + +### `POST /api/integrations/home-assistant/house/control` + +Same `{ "mode": "cool|heat|off" }` semantics as administrator house mode. + +### `POST /api/integrations/home-assistant/house/preset` + +Same `{ "preset": "auto|comfort|sleep|away" }` semantics. + +### `POST /api/integrations/home-assistant/house/power` + +Same `{ "power": true|false }` semantics. + +### `POST /api/integrations/home-assistant/zones/{id}/control` + +Same `ZoneControlPatch` thermostat semantics as the normal zone control endpoint. The internal source is recorded as Home Assistant thermostat control. + +--- + +## WebSocket + +### Connection + +Without administrator authentication: + +```text +ws://HOST:8787/ws +``` + +When `GREE_CONTROLLER_APP_TOKEN` is configured: + +```text +ws://HOST:8787/ws?token=APP_TOKEN +``` + +Generated restricted HA tokens are not WebSocket administrator tokens. + +### Message envelope + +Every server event uses: + +```json +{ + "event": "device.updated", + "timestamp": "2026-08-30T06:54:00Z", + "data": {} +} +``` + +The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, unless bootstrap generation itself fails. + +Common live events include: + +```text +bootstrap +device.created +device.updated +device.deleted +devices.discovered +zone.created +zone.updated +zone.deleted +group.created +group.updated +group.deleted +schedule.created +schedule.updated +schedule.deleted +schedule.template_applied +automation.created +automation.updated +automation.deleted +settings.updated +configuration.imported +debug.settings +api.request +gree.frame +log.created +``` + +Additional engine/integration events may be introduced without changing the envelope. + +`api.request` data: + +```json +{ + "method": "GET", + "path": "/api/system/info", + "status": 200, + "duration_ms": 2 +} +``` + +GREE debug events are emitted only when enabled and are intended for diagnostics, not as a stable protocol API. + +--- + +## Localization endpoints + +Language files are public so the UI can localize before administrator authentication. + +### `GET /lang/index.json` + +Returns the generated catalog of embedded packs. + +### `GET /lang/{code}.json` + +Returns one embedded language pack, e.g.: + +```text +GET /lang/en.json +GET /lang/pl.json +``` + +--- + +## Practical API examples + +Assume: + +```bash +BASE='http://127.0.0.1:8787' +AUTH='Authorization: Bearer APP_TOKEN' +``` + +Discover devices: + +```bash +curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"protocol_version":0,"passes":3}' +``` + +Directly poll a device: + +```bash +curl -X POST "$BASE/api/devices/DEVICE_ID/poll" -H "$AUTH" +``` + +Set a zone target: + +```bash +curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"setpoint":22.5}' +``` + +Return zone to automatic scheduling: + +```bash +curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"preset":"auto","clear_local_thermostat_override":true}' +``` + +Turn the whole managed house off: + +```bash +curl -X POST "$BASE/api/house/power" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"power":false}' +``` + +Read current ownership/desired-vs-actual state: ```bash curl "$BASE/api/control-plan" -H "$AUTH" ``` -The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also read derived group state from `GET /api/integrations/home-assistant/groups`, control a group through `POST /api/integrations/home-assistant/groups/{id}/control`, change house mode/preset/master power through `/api/integrations/home-assistant/house/*`, and change a zone target/preset/mode/enabled state through: - -Direct HA device commands are rejected while the device belongs to a disabled thermostat zone. Re-enable the zone for normal controller/HA ownership; the unrestricted `/api/devices/{id}/command` endpoint remains the technical/manual path used by the Devices view. Climate-relevant direct device commands on an enabled zone create the same manual-device override as a physical remote change, preventing the thermostat engine from immediately undoing them. - -The group list reports the configured group power state, common mode/profile (or `mixed`), member zones, online device count, average room temperature, demand count and upcoming member schedule events. Group control accepts the same semantics as the Web UI: +Read 90 days of zone history: ```bash -curl -X POST "$BASE/api/integrations/home-assistant/groups/GROUP_ID/control" \ - -H 'Authorization: Bearer HA_TOKEN' -H 'Content-Type: application/json' \ - -d '{"mode":"house","preset":"comfort"}' +curl "$BASE/api/history?scope=zones&zone_id=ZONE_ID&hours=2160" -H "$AUTH" ``` -Valid group modes are `house` (inherit global), `cool` and `heat`; valid profiles are `auto`, `comfort`, `sleep` and `away`. `power` can be sent independently as a boolean. +Create a restricted Home Assistant token: ```bash -curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \ - -H 'Authorization: Bearer HA_TOKEN' -H 'Content-Type: application/json' \ - -d '{"setpoint":22.5}' +curl -X POST "$BASE/api/access-tokens" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"name":"Home Assistant"}' ``` -## Settings backup and debug - -`GET /api/settings/export` downloads configuration JSON (settings, devices, zones, groups, schedules and automations). It intentionally excludes metric history, event logs and generated API-token records. The export includes GREE device binding keys plus configured Home Assistant and InfluxDB credentials, so treat it as a secret. - -`POST /api/settings/import` accepts that JSON format and replaces application configuration while preserving metric history, events and generated API tokens. - -Debug overlay state can be read or changed independently of the full settings document: - -```text -GET /api/debug -PUT /api/debug -``` - -Example body: `{"overlay_enabled":true,"gree_frames":true}`. With the overlay enabled, live `api.request` WebSocket events contain only HTTP method, path, status and duration. When GREE frame debug is enabled, sanitized `gree.frame` events are also emitted; the bound encryption key is not exposed. - -## WebSocket - -Connect to `ws://HOST:8787/ws?token=TOKEN`. The first message uses event type `bootstrap`; later events include `device.updated`, `zone.updated`, `settings.updated`, `debug.settings`, `api.request`, `gree.frame` and `log.created`. - -## Localization assets - -Localization endpoints are public because the login dialog also needs translations. Language packs are embedded in the Rust binary at build time. +Use that token: ```bash -curl "$BASE/lang/index.json" -curl "$BASE/lang/en.json" +curl -H 'Authorization: Bearer gree_controller_RESTRICTED_TOKEN' \ + "$BASE/api/integrations/home-assistant/control-plan" ``` - -`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` - -The history API exposes three independent data families and a combined overview. `hours` may cover up to 10 years and `limit` is capped at 20,000 rows. Query buckets become progressively wider: 30 s (<=6 h), 2 min (<=24 h), 10 min (<=7 d), 30 min (<=30 d), 2 h (<=90 d), 6 h (<=1 y), then 24 h. - -```text -GET /api/history?scope=overview&hours=24 -GET /api/history?scope=devices&device_id=DEVICE_ID&hours=2160 -GET /api/history?scope=zones&zone_id=ZONE_ID&hours=8760 -GET /api/history?scope=sensors&entity_id=sensor.room_temperature&hours=24 -``` - -Local SQLite is the hot store. When compaction is enabled, full-resolution samples are kept for 24 h, then one sample per 10-minute bucket through day 7 and one per 30-minute bucket afterwards. `history_retention_days` controls final local pruning. - -When InfluxDB is enabled, samples are written in parallel. Maintenance also backfills compacted legacy SQLite samples older than `influxdb.history_threshold_days`; those local rows are deleted only after the archive batch is accepted. Requests that cross the threshold read the older portion from InfluxDB 1.x or 2.x and merge it with recent SQLite data. If the Influx query fails, the endpoint falls back to the available SQLite history and returns `storage_warning`. - -`scope=overview` returns `zones`, `devices`, `sensors`, row `counts`, `bucket_seconds`, plus `storage`/optional `storage_warning`. Device rows contain indoor/outdoor/target/power; zone rows contain GREE/external/control/target/device-setpoint/outdoor/power/mode/fan/demand/source/preset; HA rows contain entity, optional zone, kind and temperature. - -## InfluxDB long-term storage - -Runtime settings support either InfluxDB 1.x (`version=1`, URL, database and optional username/password) or InfluxDB 2.x (`version=2`, URL, org, bucket and token). The same values can be supplied with `GREE_CONTROLLER_INFLUX_*` environment variables; see `.env.example`. An explicitly configured environment value overrides the persisted setting at startup. - - -## Event log retention - -`GET /api/events/retention` returns the current event/debug log retention window. `PUT /api/events/retention` accepts `{"days":30}` (1-3650), persists it and immediately prunes older event rows. The same value is part of runtime settings as `event_log_retention_days` and can be set at startup with `GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS`. - -## Night mode and sensor aliases - -Runtime settings include `night_mode` with `enabled`, `start_time`, `end_time`, `max_fan_speed` (1-5), `force_quiet` and `use_native_sleep`. Overnight windows crossing midnight are supported. During the window thermostat-generated fan commands are limited to the configured maximum; Auto is converted to a bounded low speed, while `Quiet` and native `Sleep` are requested only when the unit supports them. - -`home_assistant.sensor_aliases` is a map of Home Assistant `entity_id` to a friendly UI name, for example `{"sensor.gabinet_temperature":"Gabinet"}`. Aliases affect labels in the controller UI/history only. The backend canonicalizes aliases back to their original keys before Home Assistant requests and metric storage; an alias accidentally saved in a sensor field is also resolved defensively. diff --git a/docs/HOME_ASSISTANT_MIGRATION.md b/docs/HOME_ASSISTANT_MIGRATION.md deleted file mode 100644 index 4734ca0..0000000 --- a/docs/HOME_ASSISTANT_MIGRATION.md +++ /dev/null @@ -1,67 +0,0 @@ -# Home Assistant entity-ID migration - -## Goal - -Replace an existing GREE climate entity while preserving its `entity_id`. - -Example: - -```text -Before: climate.klima_salon -> default GREE integration -> AC -After: climate.klima_salon -> GREE Controller integration -> Rust controller -> AC -``` - -Keeping the same entity ID allows existing dashboards, scripts, scenes and automations that reference the entity by ID to continue working. - -## Why a takeover step is required - -Home Assistant's entity registry reserves entity IDs. A second integration cannot create another active `climate.klima_salon` while the original entity still exists. The new integration therefore checks for conflicts and stops setup rather than allowing HA to generate a suffixed name such as `climate.klima_salon_2`. - -## Generate the mapping - -For one entity: - -```bash -./scripts/generate_ha_migration.py \ - --entity climate.klima_salon \ - --device gree-aabbccddeeff -``` - -For several: - -```bash -./scripts/generate_ha_migration.py \ - --map climate.klima_salon=gree-aabbccddeeff \ - --map climate.klima_sypialnia=gree-112233445566 -``` - -Generated structure: - -```json -{ - "version": 1, - "entities": [ - { - "entity_id": "climate.klima_salon", - "device_id": "gree-aabbccddeeff" - } - ] -} -``` - -Copy it to `/config/gree_controller_entities.json` on the Home Assistant host. - -## Migration sequence - -1. Add the physical AC to the standalone Rust application. -2. Test power, mode and target temperature from its web UI. -3. Generate the HA entity mapping. -4. Copy the custom component to `/config/custom_components/gree_controller/`. -5. Disable/remove the previous GREE integration in HA so it no longer controls or publishes the old climate entity. -6. Remove any stale entity registry entry only after the old integration is unloaded. -7. Add the **GREE Controller** integration and provide its URL/token. -8. Confirm the exact old entity ID is present again. -9. Test `climate.set_temperature`, HVAC modes and power from HA. -10. Verify automations and dashboards that use the preserved entity ID. - -The standalone controller remains available during the HA migration, so the AC can still be controlled from its own web UI if HA is restarting. diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md deleted file mode 100644 index 53a33dd..0000000 --- a/docs/LOCALIZATION.md +++ /dev/null @@ -1,71 +0,0 @@ -# Localization - -The web interface uses JSON language packs from `lang/`. Language files are discovered at Rust build time and embedded in the application binary. - -English (`lang/en.json`) is required and is always the fallback language. Polish (`lang/pl.json`) is included by default. - -## Add a language - -1. Copy `lang/en.json` to a file named with the new language code, for example `lang/de.json`. -2. Update the `meta` object. -3. Translate values inside `translations`. Do not rename translation keys. -4. Run `./scripts/dev.sh --check` or build the project again. -5. Start the rebuilt binary. The new language appears automatically in the language selector. - -Example structure: - -```json -{ - "meta": { - "code": "de", - "name": "German", - "native_name": "Deutsch", - "locale": "de-DE" - }, - "translations": { - "controls.language": "Sprache", - "controls.theme": "Darstellung" - } -} -``` - -The filename and `meta.code` must match (`de.json` -> `"code": "de"`). File names may contain ASCII letters, digits, `-` and `_` only. - -## Fallback behavior - -A language pack does not have to duplicate every English key while it is being developed. If a key is missing from the selected language, the UI uses the value from `en.json`. If a key is also missing from English, the translation key itself is shown, which makes incomplete strings visible during development. - -## Build-time validation - -`build.rs` checks that: - -- at least one JSON language file exists, -- `en.json` exists, -- every language file contains valid JSON, -- every file has `meta.code`, `meta.name`, `meta.native_name` and `meta.locale`, -- `meta.code` matches the filename, -- `translations` is a JSON object. - -A malformed language pack fails the Rust build instead of producing a broken selector at runtime. - -## Runtime endpoints - -The embedded language catalog is available at: - -```text -GET /lang/index.json -``` - -Individual embedded packs are available at: - -```text -GET /lang/en.json -GET /lang/pl.json -GET /lang/.json -``` - -These endpoints are intentionally public so that localization also works before API authentication is completed. - -## Browser preference - -The selected language code is stored for one year in the `gree_controller_language` cookie. If the stored language is no longer present in a later build, the UI falls back to English. diff --git a/docs/LXC.md b/docs/LXC.md deleted file mode 100644 index fe0b6c7..0000000 --- a/docs/LXC.md +++ /dev/null @@ -1,177 +0,0 @@ -# LXC installation and update - -This document describes the supported Debian/Ubuntu systemd deployment used for LXC testing. - -## First installation - -Unpack the source archive inside the container and run: - -```bash -cd gree-controller -chmod +x scripts/*.sh -sudo ./scripts/install.sh -``` - -The installer: - -1. installs required build packages when missing, -2. installs stable Rust with rustup when Cargo is unavailable, -3. runs `cargo test --all-targets`, -4. builds `target/release/gree-controller`, -5. creates the `gree-controller` system user/group, -6. creates `/var/lib/gree-controller`, `/opt/gree-controller` and `/var/backups/gree-controller`, -7. installs the systemd unit, -8. creates `/etc/gree-controller.env` only when it does not already exist, -9. enables/restarts the service, -10. verifies `GET /api/health`. - -Installed state is intentionally outside the extracted source directory: - -```text -/opt/gree-controller/gree-controller installed binary -/etc/gree-controller.env persistent configuration/secrets -/var/lib/gree-controller/gree-controller.db persistent SQLite database -/var/backups/gree-controller/ update backups -/etc/systemd/system/gree-controller.service systemd service -``` - -The default installation starts in simulator mode. Change `GREE_CONTROLLER_SIMULATE=false` and `GREE_CONTROLLER_AUTO_SEED=false` in `/etc/gree-controller.env` when moving to physical units, then restart the service. - -## Update - -Unpack a newer source archive and run from that new directory: - -```bash -sudo ./scripts/update.sh -``` - -The update workflow is designed to minimize downtime and preserve the database: - -1. build dependencies/Rust are checked, -2. tests run while the currently installed controller stays online, -3. the new release binary is built while the old service stays online, -4. the service is stopped, -5. the installed binary, service unit, environment file and SQLite database are copied to `/var/backups/gree-controller//`, -6. the new binary/unit is installed, -7. systemd starts the new version, -8. `/api/health` is checked, -9. on failure the old binary, unit and database are restored automatically. - -The updater does not overwrite `/etc/gree-controller.env` during a successful update. - -Use `--skip-tests` only for deliberate fast testing: - -```bash -sudo ./scripts/update.sh --skip-tests -``` - -## Service helper - -```bash -./scripts/service.sh status -./scripts/service.sh health -./scripts/service.sh logs -sudo ./scripts/service.sh restart -sudo ./scripts/service.sh stop -sudo ./scripts/service.sh start -``` - -Equivalent native commands remain available: - -```bash -systemctl status gree-controller -journalctl -u gree-controller -f -``` - -## Configuration - -Edit: - -```text -/etc/gree-controller.env -``` - -and restart: - -```bash -sudo ./scripts/service.sh restart -``` - -Do not store controller or Home Assistant secrets in the source tree. - -## Database and SQL layout - -Runtime persistence uses SQLite. All schema definitions and SQL statements in the Rust application are centralized in: - -```text -src/queries.rs -``` - -`src/db.rs` contains connection/transaction logic and maps database rows to Rust domain models, but it does not embed SQL statements. - - -## Two network interfaces - -When the LXC has a management interface and a dedicated GREE network, configure the service environment explicitly. Example for `eth1` on `10.87.65.0/25`: - -```env -GREE_CONTROLLER_GREE_INTERFACE=eth1 -GREE_CONTROLLER_DISCOVERY_BROADCAST=auto - -This explicit interface configuration is recommended for predictable LXC deployments. Version 0.3.7 also automatically selects the directly connected local IPv4 address for a GREE device when the interface variable is omitted; for example, a target in `10.87.65.0/25` selects the local address on that subnet. -GREE_CONTROLLER_SIMULATE=false -GREE_CONTROLLER_AUTO_SEED=false -``` - -Restart the service after editing `/etc/gree-controller.env`: - -```bash -systemctl restart gree-controller -journalctl -u gree-controller -n 100 --no-pager -``` - -During discovery the log should contain a line similar to: - -```text -Starting GREE discovery target=10.87.65.127:7000 local=10.87.65.27: interface=eth1 -``` - -Use `scripts/network-debug.sh` for routing diagnostics. - - -## Mixed GREE model generations - -Version 0.3.7 can discover both AES-ECB and AES-GCM modules. In the Web UI choose **Discover -> Auto (V1 + V2)** and use 3-5 scan passes. If a family is still missing, repeat with V1-only and V2-only to see which protocol its Wi-Fi module answers with. - -A single command/status timeout no longer immediately flips a device offline; offline requires three consecutive communication failures. - -## Multi-NIC LXC and AF_NETLINK - -When `GREE_CONTROLLER_GREE_INTERFACE` is set, the controller enumerates IPv4 addresses with Linux `getifaddrs()`. On Linux this requires a Netlink socket. The systemd sandbox therefore allows `AF_NETLINK` in addition to `AF_UNIX`, `AF_INET`, and `AF_INET6`. - -If an older unit reports: - -```text -getifaddrs failed -Address family not supported by protocol (os error 97) -``` - -update the systemd unit or add `AF_NETLINK` to `RestrictAddressFamilies`, then run: - -```bash -systemctl daemon-reload -systemctl restart gree-controller -``` - -For a dedicated GREE NIC such as `eth1`, the expected startup/bind log should identify the IPv4 address of that interface instead of `0.0.0.0`. - -### Legacy V1 devices discovered but not binding - -Version 0.3.8 sends the GREE protocol `tcid` and inner `mac` identifiers in canonical lowercase hexadecimal. If an older `502cc6...` device is discovered on UDP/7000 but stays offline after bind timeouts, update to v0.3.8 before changing routing or firewall settings. With debug logging, `Sending GREE request` should show `wire_mac=502cc6...` in lowercase. - - -## Home Assistant HTTPS with a self-signed certificate - -The optional outbound HA sensor client validates TLS certificates by default. For a trusted local endpoint such as `https://10.87.65.2` that uses a self-signed, expired or hostname-mismatched certificate, enable **Settings -> Allow invalid/self-signed HTTPS certificate** and save. The same initial setting can be supplied as `HA_ALLOW_INVALID_TLS=true`. - -This opt-in affects only controller -> Home Assistant sensor requests. It does not change the GREE UDP transport and it does not add HTTPS termination to the controller itself. diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md deleted file mode 100644 index 456dfb2..0000000 --- a/docs/PROJECT_SPEC.md +++ /dev/null @@ -1,196 +0,0 @@ -# GREE Controller project specification - -## Product goal - -Build an autonomous local GREE HVAC controller running primarily as a Rust service in a dedicated Linux/LXC environment. Home Assistant is optional: it can provide external sensor data and can consume controller devices through a thin custom integration, but it must not contain the core GREE protocol or heating logic. - -## Architecture - -```text -Home Assistant / Web UI / REST clients - | - REST + WebSocket - | - GREE Controller (Rust) - +--------------------------------+ - | Device/state manager | - | GREE protocol UDP/AES | - | Zone/heating engine | - | Scheduler | - | Automation engine | - | SQLite | - | Mobile-first web UI | - +--------------------------------+ - | - UDP/7000 - | - GREE AC -``` - -## Independence requirements - -- GREE control must work when Home Assistant is stopped. -- Local schedules and automations must continue without HA. -- HA sensor failures must never remove basic access to the AC. -- Device state is persisted by the controller and can be restored to clients after reconnect/restart. - -## GREE protocol layer - -The protocol implementation is isolated from application logic and covers: - -- discovery, -- packet encoding/decoding, -- AES-128-ECB support, -- AES-128-GCM envelope support, -- bind/key acquisition, -- status polling, -- command transport, -- reconnect/offline handling. - -The rest of the application works with generic device state and commands rather than raw protocol packets. - -## Device model - -Each device stores: - -- stable controller ID, -- MAC/CID, -- name, -- IP/port, -- protocol version, -- model/firmware when available, -- encryption key when bound, -- enabled/simulated flags, -- power and HVAC mode, -- target/current/outdoor temperature, -- fan, swing, quiet, turbo, light, -- online/last-seen/error state. - -## Smart thermostat, zones and schedules - -The primary user model is a fast thermostat, not raw device automation. A global climate mode selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. The UI labels this as **Mode policy**: keep **Follow global mode** when the zone should inherit the mode selected globally in GREE Controller; the fixed Cooling/Heating policies are explicit overrides. House `off` is a **do not control** state for inherited zones: manual device operation is left untouched, while a zone explicitly switched to Heat/Cool can operate independently. Whole-house power on/off is a separate master state. Master Off is authoritative for zones and controller automations but does not modify the selected house thermostat mode. - -During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**: - -- when the zone requires conditioning, use the active target (optionally assisted slightly by outdoor weather), -- when the zone is satisfied, move the device setpoint to the non-demand side of the room target so the inverter/compressor can stop naturally, -- use hysteresis and a minimum adjustment interval to avoid command chatter, -- optionally adjust fan speed based on room error and outdoor extremes; with Smart fan enabled, a satisfied zone uses Low fan together with the standby setpoint and requests Quiet mode in the same frame when the unit supports it. Unsupported Quiet commands automatically fall back to Low fan without breaking thermostat control. - -Each zone has separate seasonal profile temperatures: - -- Cooling: Comfort / Sleep / Away, -- Heating: Comfort / Sleep / Away. - -The zone can use the GREE internal sensor, its own Home Assistant room sensor, or a weighted combination. External room sensors are configured per zone; loss/discrepancy falls back safely to the GREE sensor. - -Weekly schedules reference profiles instead of duplicating temperatures. Ready-made Family, Child room, Bedroom, Workday and Always-comfort templates create ordinary schedule rows that remain fully editable. A manual **Sleep**, Comfort, Away or custom-temperature override ends automatically at the next schedule boundary; if no future transition exists, it remains active until explicitly cleared. Whole-house preset actions apply the same temporary policy to every zone. - -Automations remain available for advanced exceptions. Daily comfort should be implemented with zones/profiles/schedules so direct-device automation commands do not compete with the thermostat engine. - -Climate groups are named collections of thermostat zones (for example Upstairs/Downstairs). Group commands can independently set power, Heat/Cool/house-follow mode and Auto/Comfort/Sleep/Away profile for only their member zones. Automation actions may target either a direct device or a climate group. - -### Outdoor temperature assist - -A configured Home Assistant outdoor-temperature entity is optional auxiliary context. It never replaces the room-control temperature. Under strong heat/cold the engine may slightly bias the active AC target and increase Smart Fan airflow. If HA is unavailable, the smart thermostat continues without outdoor assistance. - -## Scheduler and automations - -Schedules are weekly time windows and may cross midnight. Equal start/end times mean a 24-hour block for each selected weekday. Enabled schedules for one zone may not overlap. Schedules can select Comfort/Sleep/Away or a custom setpoint. Automations support time or temperature triggers and device commands with cooldown protection; a failed command also observes the cooldown instead of retrying every control cycle. They are intended for exceptions rather than the normal daily thermostat cycle. - -The automation engine runs in Rust and does not require Home Assistant YAML automation logic. - -## API - -The controller exposes REST for configuration/commands and WebSocket for live state updates. The HA integration and web UI use the same controller API. - -## Home Assistant direction 1: external sensor input - -The Rust application can read a specifically configured HA entity using a Long-Lived Access Token. This input is optional and is not a prerequisite for GREE control. - -## Home Assistant direction 2: native climate entities - -A custom integration creates HA climate entities and translates HA service calls to controller API commands. It does not communicate with GREE directly. - -For migrations from the default GREE integration, a mapping file can request an existing entity ID such as `climate.klima_salon`. The previous integration must release that ID before takeover. - -## Web UI - -Primary use is from a phone. Requirements: - -- responsive mobile-first layout, -- touch-friendly controls, -- live state updates, -- device, zone, schedule, automation, history and diagnostics screens, -- JSON-based language packs from `lang/*.json`, with English as the required default/fallback and Polish included, -- automatic language discovery at build time, so a new valid `.json` file adds a language without JavaScript changes, -- language stored in the `gree_controller_language` cookie, -- system/light/dark appearance modes, -- appearance stored in a cookie, -- flat visual design without decorative shadows, -- no letter-logo badge next to the application name. - -## Storage - -SQLite stores configuration, state, readings and events. PostgreSQL or other external database services are not required for the single-node deployment target. - -## Deployment - -Primary target: - -```text -LXC / Debian or Ubuntu - /opt/gree-controller/gree-controller - /etc/gree-controller.env - /var/lib/gree-controller/gree-controller.db - systemd: gree-controller.service -``` - -A development script prepares dependencies/build/runtime configuration, and a separate installer creates the systemd deployment. - -## Security - -- optional administrator Bearer token for the controller Web/API, -- separately generated, revocable Home Assistant client tokens stored only as SHA-256 hashes, -- Home Assistant client tokens are restricted to device read/control endpoints, -- secrets kept outside source control, -- no direct public Internet exposure, -- TLS delegated to a trusted reverse proxy/VPN when required, -- local device keys and HA token protected in environment/database files, -- invalid/self-signed HA HTTPS certificates are rejected by default; bypass is an explicit trusted-LAN opt-in. - - -## Per-zone room sensors - -A zone represents one room and normally maps one GREE indoor unit to one optional external room-temperature sensor. External sensors are not global. For example, Living Room can use `sensor.living_room_temperature` while Bedroom independently uses `sensor.bedroom_temperature`. - -The zone controller supports `device`, `combined`, and `home_assistant` temperature strategies. Combined mode uses a configurable external-sensor weight (40% by default), validates the difference between sensors, and falls back to the GREE sensor when the external source is missing or outside the configured discrepancy limit. The local GREE measurement therefore remains available even when Home Assistant is offline. - - -## Operational packaging conventions - -- All operator-facing shell/Python utilities live under `scripts/`. -- `scripts/install.sh` performs the first systemd/LXC installation. -- `scripts/update.sh` performs an in-place update with a stopped SQLite backup, health check and automatic rollback. -- `scripts/service.sh` provides common systemd operations. -- Cargo's `build.rs` stays at the package root because Cargo requires that location. -- Every SQLite statement and schema definition is centralized in `src/queries.rs`; database/domain code must not embed SQL strings elsewhere. - - -## Web UI design system (v0.4.4) - -The embedded UI uses the classic GREE Controller visual language: large rounded surfaces, circular thermostat controls, comfortable spacing, compact desktop navigation and mobile bottom navigation. Light, Dark and System themes use neutral surfaces with a restrained green accent. - -The UI ships as a normal static `web/styles.css` file embedded into the Rust binary. There is no frontend package manager, 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. - -### History information architecture (v0.4.4) - -History is not a single long page. It is split into URL-addressable views: Overview, Zones, GREE devices, HA sensors and Custom chart. Device history exists independently from zones, HA sensors have their own history stream, and zone history adds the thermostat/control context. A compatibility fallback maps existing GREE device readings into a zone timeline when the richer zone table is still empty. - -The custom chart composer can mix GREE indoor/outdoor/target values, zone GREE/HA/control/target/device-setpoint/outdoor values, and HA temperature entities. Saved definitions persist in browser storage and a chart definition can be serialized into the `/history/custom?chart=...` URL for direct sharing/bookmarking. - -Quick thermostat temperature nudges are optimistic in the browser and stored separately from the selected Comfort/Sleep/Away/Auto preset. Changing `+/-` therefore does not silently leave Auto mode. diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md deleted file mode 100644 index f5e95c0..0000000 --- a/docs/REVERSE_PROXY.md +++ /dev/null @@ -1,32 +0,0 @@ -# Reverse proxy - -The web UI supports HTTPS/WSS and sub-path deployments. The server honors `GREE_CONTROLLER_BASE_PATH` and also understands `X-Forwarded-Prefix` when a proxy strips the prefix before forwarding. - -## Root deployment - -Proxy `/` to `http://127.0.0.1:8787` and forward WebSocket upgrade headers. No controller setting is required. - -## Sub-path deployment - -For a public path such as `/gree`, either: - -- set `GREE_CONTROLLER_BASE_PATH=/gree` and proxy `/gree` to the controller without stripping the path, or -- strip `/gree` in the proxy and send `X-Forwarded-Prefix: /gree`. - -Static assets, language packs, browser routes, API requests, PWA scope and WebSocket URLs use the effective base path. - -### nginx example (prefix stripped) - -```nginx -location /gree/ { - proxy_pass http://127.0.0.1:8787/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Prefix /gree; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; -} -``` - -For an Internet-facing endpoint, add TLS and proxy authentication. If the application token is enabled, avoid access-log formats that record query strings because the browser WebSocket connection includes the token in its URL. diff --git a/docs/SECURITY.md b/docs/SECURITY.md deleted file mode 100644 index 69acc55..0000000 --- a/docs/SECURITY.md +++ /dev/null @@ -1,31 +0,0 @@ -# Security notes - -The controller is designed primarily for a trusted LAN. This release adds safe defaults without forcing authentication on existing installations. - -## Implemented safeguards - -- `GREE_CONTROLLER_APP_TOKEN=` remains supported. Authentication is optional and existing installations are compatible. -- Permissive CORS was removed. The web UI and API are same-origin by default. -- Responses include `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy` and a restrictive `Permissions-Policy`; API responses are marked `no-store`. -- Runtime secrets for Home Assistant, InfluxDB and notifications are stored server-side and never returned to the browser. Empty secret fields keep the previously stored value. -- Slack and Discord notification webhooks require HTTPS and are restricted to the official webhook hosts. Redirect following is disabled for these outbound webhook requests. Pushover uses its fixed official API endpoint. -- API access tokens are stored hashed. Administrator token behavior is unchanged for backwards compatibility. -- History/event API limits and SQLite indexes bound common read paths. History retention/compaction is configurable. - -## Remaining risks / recommendations - -1. With an empty application token, every client that can reach the controller HTTP port can operate the air conditioners and change configuration. Keep port 8787 on a trusted VLAN or put authentication in the reverse proxy. -2. The WebSocket administrator token is still passed in its query string because browser WebSocket APIs cannot set an Authorization header. Avoid logging query strings at the proxy and prefer HTTPS/WSS when a token is enabled. -3. The application does not terminate TLS. Use a reverse proxy or VPN for traffic crossing an untrusted network. -4. Home Assistant `allow_invalid_tls` should be used only with a known local instance; keep it disabled otherwise. -5. Configuration exports contain device keys and may contain integration secrets. Treat backup files as secrets. -6. There is no per-client API rate limiter. For exposure beyond a trusted LAN, configure rate limiting at the reverse proxy. -7. No CSRF token is used. Same-origin operation, removal of permissive CORS and bearer-token authentication reduce the risk, but an authenticated public deployment should also use proxy-level origin/access controls. - -## Suggested reverse-proxy hardening - -- TLS only; redirect HTTP to HTTPS. -- Do not log URL query strings if administrator authentication is enabled. -- Pass WebSocket upgrades for `/ws`. -- Add authentication at the proxy when `GREE_CONTROLLER_APP_TOKEN` is empty and the service is reachable outside the trusted LAN. -- Limit request body sizes and add basic request-rate limiting. diff --git a/future.md b/future.md deleted file mode 100644 index dd1ad11..0000000 --- a/future.md +++ /dev/null @@ -1,73 +0,0 @@ -1. Timer powrotu do automatyzacji - -Backend już przechowywał local_thermostat_resume_at, ale przy takim scenariuszu: - -lokalny OFF → licznik 15 min → pilot/direct ON → pilot/direct OFF - -stary deadline nadal istniał. Sterowanie pilotem miało wyższy priorytet, ale timer lokalnego OFF nadal „leciał pod spodem”. - -Poprawiłem to tak: - -dodałem centralną metodę set_local_thermostat_power(...), -każde nowe OFF ustawia świeże now + 15 min, -ON kasuje deadline, -podczas device_manual_override timer lokalnego OFF nie może wygasnąć, -gdy użytkownik po pilocie/direct wróci do wcześniejszego stanu OFF, backend ustawia nowe pełne 15 minut, -HA bierze dokładny local_thermostat_resume_at zwrócony przez backend, więc stary odczyt nie powinien chwilowo przywracać poprzedniego timera. - -Frontend nadal może odświeżać MM:SS co sekundę lokalnie — to jest właściwe. Frontend tylko wyświetla czas, backend decyduje kiedy przejąć sterowanie. Jeśli chcesz całkowicie uniezależnić wyświetlanie od zegara przeglądarki, później można dodać server_time albo remaining_seconds. - -2. Pilot + Home Assistant + Web — jak powinno to działać - -Tutaj obecna architektura jest już częściowo dobra. Masz dwa różne rodzaje poleceń: - -bezpośrednie urządzenie — pilot, Web „Devices”, fizyczny climate w HA, -termostat strefy — Quick Thermostat w Web i climate._thermostat w HA. - -Logicznie ustawiłbym następujące zasady: - -Pilot/direct przejmuje urządzenie. Powstaje device_manual_override. Harmonogram, grupa i automatyka nie walczą wtedy z człowiekiem. -Kolejna komenda direct z HA/Web zmienia tę samą ręczną sesję i nadal nie uruchamia automatyki. -Komenda do termostatu strefy oznacza świadome: „teraz znowu steruje regulator”. Wtedy device_manual_override jest kasowany i zaczyna obowiązywać termostat. -Grupy, harmonogram i automatyzacje nie powinny po cichu odbierać sterowania pilotowi. -Globalne OFF powinno pozostać nadrzędne — i obecny kod właśnie tak działa. - -To jest bardzo sensowna polityka. - -W panelu dodałbym jednak jawny status jednego właściciela: - -Automatyka / Termostat lokalny / Pilot / HA — sterowanie bezpośrednie / Web — sterowanie bezpośrednie / Globalnie wyłączone - -oraz: - -od kiedy, -do kiedy, -dlaczego, -przycisk Przejmij sterowanie termostatem. - -API też powinno to wystawiać, np. logicznie jako: - -control_owner -control_source -control_since -resume_at - -Obecnie device_manual_override jest zapisany, ale dokładne źródło przejęcia jest głównie w logach. Przy dalszym rozwoju warto zrobić z tego normalny stan. - -Szczególnie w HA rozważyłbym, żeby fizyczny climate urządzenia był oznaczony jako „sterowanie bezpośrednie / zaawansowane”, a głównym encją użytkową był termostat strefy. Teraz użytkownik może mieć dwa climate dla jednego klimatyzatora, które celowo mają inne znaczenie — łatwo się pomylić. - -3. Co jeszcze brakuje przed używaniem tego jako głównego ogrzewania/chłodzenia - -Najważniejsze rzeczy, w tej kolejności: - -Blokada współbieżnych zmian strefy. To jest realny problem w obecnym kodzie. HA i Web mogą prawie równocześnie pobrać ten sam Zone, zmienić różne pola i zapisać cały JSON. Ostatni zapis może nadpisać poprzednią zmianę. Dałbym per-zone mutex oraz docelowo revision/optimistic locking. To jest dla mnie najwyższy następny priorytet. -Formalny model własności sterowania. Zamiast sprawdzania w wielu miejscach device_manual_override, local_thermostat_power, group gate itd., jedna metoda typu resolve_control_owner() powinna określać kto aktualnie może wysyłać komendy. Panel, HA i engine korzystałyby z tej samej odpowiedzi. -Ochrona przed szybkim przełączaniem Heat ↔ Cool / OFF ↔ ON. W modelu masz min_on_seconds i min_off_seconds, ale obecnie praktycznie nie są wykorzystywane przez regulator. Zmiana trybu jest wręcz traktowana jako pilna. Dla głównego źródła ogrzewania dodałbym minimum 3–5 minut blokady po wyłączeniu oraz przy zmianie Heat/Cool. -Kontrola świeżości czujnika HA. read_temperature() sprawdza, czy wartość jest liczbą, ale nie sprawdza wieku last_updated. Sensor może wisieć z poprawną wartością przez wiele godzin. Potrzebny jest np. sensor_stale_after=5 min, fallback na GREE i informacja/alarm. -Tryby ręcznego „hold”. Zamiast jednej zasady warto mieć: 15 min, 30 min, 1 h, do kolejnego harmonogramu, do godziny..., bezterminowo. Ten sam mechanizm można potem wykorzystać dla temperatury, profilu, pilota i lokalnego OFF. -Zmiana harmonogramu podczas ręcznego przejęcia. Kod aktualizuje granicę manual_override_until dla ręcznego profilu/setpointu po edycji harmonogramu, ale nie widzę analogicznego przeliczania device_manual_override_until. To może pozostawić nieaktualny termin przejęcia po zmianie grafiku. -Desired state vs actual state. Warto w API jasno publikować „czego chce regulator” i „co faktycznie ma klimatyzator”, plus powód rozbieżności: pilot, offline, timer, group OFF, lockout. Część tego już masz w control-plan, więc to jest naturalne rozszerzenie. -Ochrona temperaturowa. Dla głównego ogrzewania przyda się opcjonalny „frost protection”, np. alarm lub awaryjne grzanie poniżej określonej temperatury. Powinno być osobno konfigurowalne, żeby świadome globalne OFF nie zostało niespodziewanie złamane. -Dopiero później rozważałbym automatyczne Heat/Cool na podstawie temperatury z dużą martwą strefą i minimalnym czasem pozostawania w jednym trybie. - -Najbliższa zmiana architektoniczna, którą zrobiłbym teraz, to więc ControlOwnership + per-zone locking/revision. To rozwiąże większość przyszłych konfliktów HA ↔ Web ↔ pilot w jednym miejscu, zamiast dodawać kolejne wyjątki. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 8a25ddb..9f90cad 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.10", + "version": "0.8.13", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 4788c1d..2bcef7d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -406,8 +406,8 @@ "settings.importConfirm": "Replace the current application configuration with this file? Existing metrics and API access tokens will be kept.", "debug.title": "Live debug", "debug.clear": "Clear", - "debug.apiAndGree": "API logs + GREE frames", - "debug.apiOnly": "API logs", + "debug.apiAndGree": "Requests, events + GREE frames", + "debug.apiOnly": "Requests and application events", "debug.empty": "No debug events yet.", "toast.exported": "Configuration exported", "toast.imported": "Configuration imported", @@ -692,6 +692,82 @@ "notifications.alertControlErrors": "Thermostat and group control errors", "notifications.alertImportantEvents": "Important state changes", "notifications.alertOther": "Other warnings and errors", - "notifications.testSent": "Test notification sent" + "notifications.testSent": "Test notification sent", + "actions.close": "Close", + "actions.remove": "Remove", + "plan.strategySetpoint": "Setpoint", + "simulation.running": "RUNNING", + "simulation.idle": "IDLE", + "logs.info": "Info", + "logs.days7": "7 days", + "logs.days14": "14 days", + "logs.days30": "30 days", + "logs.days90": "90 days", + "logs.days180": "180 days", + "logs.days365": "365 days", + "history.hoverHint": "Hover, tap or use ←/→ for exact values. Use +/− to zoom and swipe to pan.", + "actions.saving": "Saving…", + "actions.retry": "Retry", + "auth.connecting": "Connecting…", + "confirm.discardChanges": "Discard unsaved changes?", + "status.offlineHint": "Live updates are unavailable. Displayed data may be stale.", + "validation.required": "This field is required.", + "validation.url": "Enter a valid http:// or https:// URL.", + "validation.range": "Enter a value from {min} to {max}.", + "validation.invalid": "Check this value.", + "validation.entityId": "Use a Home Assistant entity_id such as sensor.living_room_temperature.", + "validation.fixFields": "Correct the highlighted fields before saving.", + "history.zoomControls": "Chart zoom", + "history.zoomOut": "Zoom out", + "history.zoomReset": "Reset zoom", + "history.zoomIn": "Zoom in", + "house.powerOffConfirm": "Turn off all air conditioners and disable all groups?", + "zones.temporaryStarting": "Starting…", + "notifications.applicationTokenPlaceholder": "Application token", + "notifications.userKeyPlaceholder": "User/group key", + "settings.testingHa": "Testing…", + "settings.settingsSections": "Settings sections", + "settings.applicationSettings": "Application", + "settings.greeSettings": "GREE", + "settings.applicationRuntime": "Application runtime", + "settings.applicationRuntimeHint": "Application-wide behavior that does not change GREE transport or discovery.", + "settings.simulationModeHint": "When enabled, a prominent simulation warning is shown throughout the application.", + "settings.greeConnection": "GREE connection", + "settings.greeConnectionHint": "Controller identity, polling intervals and LAN discovery transport.", + "settings.systemStatusTitle": "Controller health", + "settings.systemStateHint": "Live runtime, connectivity and storage details useful during setup and diagnostics.", + "settings.controlEngine": "Control engine", + "settings.ready": "Ready", + "settings.syncing": "Synchronizing", + "settings.websocket": "Live connection", + "settings.runtimeMode": "Runtime mode", + "settings.runtimeSimulation": "Simulation", + "settings.runtimeLive": "Live devices", + "settings.devicesConfigured": "Configured devices", + "settings.devicesOnline": "Devices online", + "settings.simulatedDevices": "Simulated devices", + "settings.receivedFrames": "GREE frames received", + "settings.httpBind": "HTTP bind", + "settings.greeInterface": "GREE interface", + "settings.database": "Database", + "settings.basePath": "Base path", + "settings.healthy": "Healthy", + "settings.attention": "Check status", + "debug.filterAria": "Debug source", + "debug.all": "All", + "debug.requests": "Requests", + "debug.gree": "GREE", + "debug.emptyRequests": "No HTTP requests captured yet.", + "debug.emptyGree": "No GREE frames captured yet.", + "simulation.bannerTitle": "SIMULATION MODE", + "simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data.", + "settings.pollCycle": "Device poll interval", + "settings.zoneCycle": "Zone control interval", + "settings.homeAssistantStatus": "Home Assistant", + "settings.influxStatus": "InfluxDB history", + "settings.notificationsStatus": "Notifications", + "settings.debugStatus": "Debug overlay", + "settings.configured": "configured", + "settings.notConfigured": "not configured" } } diff --git a/lang/pl.json b/lang/pl.json index 342fc0e..a5473ef 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -39,7 +39,7 @@ "actions.edit": "Edytuj", "actions.delete": "Usuń", "actions.read": "Odczyt", - "actions.bind": "Bind", + "actions.bind": "Powiąż", "actions.noChange": "Bez zmiany", "actions.turnOn": "Włącz", "actions.turnOff": "Wyłącz", @@ -82,8 +82,8 @@ "zones.greeSensor": "Tylko GREE", "zones.combinedSensor": "GREE + czujnik pokojowy", "zones.externalSensor": "Tylko czujnik pokojowy", - "zones.minOn": "Min. ON (s)", - "zones.minOff": "Min. OFF (s)", + "zones.minOn": "Min. czas WŁ. (s)", + "zones.minOff": "Min. czas WYŁ. (s)", "zones.greeSource": "sensor GREE", "zones.combinedSource": "GREE + czujnik pokojowy", "zones.externalSource": "czujnik pokojowy", @@ -144,7 +144,7 @@ "settings.haTokenSaved": "Token zapisany — pozostaw puste", "settings.haLongLivedToken": "Długotrwały token dostępu", "settings.defaultEntity": "Domyślny entity_id", - "settings.testHa": "Test HA", + "settings.testHa": "Testuj HA", "settings.systemState": "Stan systemu", "settings.version": "Wersja", "settings.uptime": "Czas pracy", @@ -406,8 +406,8 @@ "settings.importConfirm": "Zastąpić bieżącą konfigurację aplikacji tym plikiem? Istniejące metryki i tokeny dostępu API zostaną zachowane.", "debug.title": "Debug na żywo", "debug.clear": "Wyczyść", - "debug.apiAndGree": "Logi API + ramki GREE", - "debug.apiOnly": "Logi API", + "debug.apiAndGree": "Requesty, zdarzenia + ramki GREE", + "debug.apiOnly": "Requesty i zdarzenia aplikacji", "debug.empty": "Brak zdarzeń debug.", "toast.exported": "Konfiguracja wyeksportowana", "toast.imported": "Konfiguracja zaimportowana", @@ -500,7 +500,7 @@ "devices.xfan": "X-FAN", "devices.health": "Health", "devices.air": "Air", - "devices.sleep": "Sleep", + "devices.sleep": "Sen", "nav.nightMode": "Tryb nocny", "nav.homeAssistant": "Home Assistant / Czujniki", "night.eyebrow": "Komfort", @@ -526,7 +526,7 @@ "notifications.test": "Wyślij powiadomienie testowe", "zones.copyFrom": "Kopiuj ustawienia termostatu z…", "zones.copySettings": "Kopiuj ustawienia", - "automations.preset": "Preset…", + "automations.preset": "Szablon…", "automations.presetHot": "Chłodzenie przy wysokiej temperaturze", "automations.presetCold": "Grzanie przy niskiej temperaturze", "automations.presetMorning": "Komfort rano", @@ -692,6 +692,82 @@ "notifications.alertControlErrors": "Błędy sterowania termostatami i grupami", "notifications.alertImportantEvents": "Ważne zmiany stanu", "notifications.alertOther": "Pozostałe ostrzeżenia i błędy", - "notifications.testSent": "Wysłano powiadomienie testowe" + "notifications.testSent": "Wysłano powiadomienie testowe", + "actions.close": "Zamknij", + "actions.remove": "Usuń", + "plan.strategySetpoint": "Nastawa", + "simulation.running": "PRACA", + "simulation.idle": "CZUWA", + "logs.info": "Informacje", + "logs.days7": "7 dni", + "logs.days14": "14 dni", + "logs.days30": "30 dni", + "logs.days90": "90 dni", + "logs.days180": "180 dni", + "logs.days365": "365 dni", + "history.hoverHint": "Najedź, dotknij lub użyj ←/→, aby zobaczyć dokładne wartości. Użyj +/− do powiększenia i przesuń wykres palcem.", + "actions.saving": "Zapisywanie…", + "actions.retry": "Ponów", + "auth.connecting": "Łączenie…", + "confirm.discardChanges": "Odrzucić niezapisane zmiany?", + "status.offlineHint": "Aktualizacje na żywo są niedostępne. Wyświetlane dane mogą być nieaktualne.", + "validation.required": "To pole jest wymagane.", + "validation.url": "Podaj poprawny adres URL http:// lub https://.", + "validation.range": "Podaj wartość od {min} do {max}.", + "validation.invalid": "Sprawdź tę wartość.", + "validation.entityId": "Podaj entity_id Home Assistanta, np. sensor.salon_temperature.", + "validation.fixFields": "Popraw zaznaczone pola przed zapisaniem.", + "history.zoomControls": "Powiększenie wykresu", + "history.zoomOut": "Pomniejsz wykres", + "history.zoomReset": "Przywróć powiększenie", + "history.zoomIn": "Powiększ wykres", + "house.powerOffConfirm": "Wyłączyć wszystkie klimatyzatory i dezaktywować wszystkie grupy?", + "zones.temporaryStarting": "Uruchamianie…", + "notifications.applicationTokenPlaceholder": "Token aplikacji", + "notifications.userKeyPlaceholder": "Klucz użytkownika/grupy", + "settings.testingHa": "Testowanie…", + "settings.settingsSections": "Sekcje ustawień", + "settings.applicationSettings": "Aplikacja", + "settings.greeSettings": "GREE", + "settings.applicationRuntime": "Praca aplikacji", + "settings.applicationRuntimeHint": "Ustawienia całej aplikacji niezwiązane z transportem ani wykrywaniem GREE.", + "settings.simulationModeHint": "Po włączeniu na całym ekranie będzie widoczne wyraźne ostrzeżenie o trybie symulacji.", + "settings.greeConnection": "Połączenie GREE", + "settings.greeConnectionHint": "Identyfikator kontrolera, interwały odczytu i transport wykrywania urządzeń w LAN.", + "settings.systemStatusTitle": "Kondycja kontrolera", + "settings.systemStateHint": "Bieżące informacje o pracy, połączeniu i pamięci danych przydatne podczas konfiguracji i diagnostyki.", + "settings.controlEngine": "Silnik sterowania", + "settings.ready": "Gotowy", + "settings.syncing": "Synchronizacja", + "settings.websocket": "Połączenie na żywo", + "settings.runtimeMode": "Tryb pracy", + "settings.runtimeSimulation": "Symulacja", + "settings.runtimeLive": "Urządzenia fizyczne", + "settings.devicesConfigured": "Skonfigurowane urządzenia", + "settings.devicesOnline": "Urządzenia online", + "settings.simulatedDevices": "Urządzenia symulowane", + "settings.receivedFrames": "Odebrane ramki GREE", + "settings.httpBind": "Nasłuch HTTP", + "settings.greeInterface": "Interfejs GREE", + "settings.database": "Baza danych", + "settings.basePath": "Ścieżka bazowa", + "settings.healthy": "Działa prawidłowo", + "settings.attention": "Sprawdź stan", + "debug.filterAria": "Źródło debug", + "debug.all": "Wszystko", + "debug.requests": "Requesty", + "debug.gree": "GREE", + "debug.emptyRequests": "Brak przechwyconych requestów HTTP.", + "debug.emptyGree": "Brak przechwyconych ramek GREE.", + "simulation.bannerTitle": "TRYB SYMULACJI", + "simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe.", + "settings.pollCycle": "Interwał odczytu urządzeń", + "settings.zoneCycle": "Interwał regulatora stref", + "settings.homeAssistantStatus": "Home Assistant", + "settings.influxStatus": "Historia InfluxDB", + "settings.notificationsStatus": "Powiadomienia", + "settings.debugStatus": "Panel debug", + "settings.configured": "skonfigurowany", + "settings.notConfigured": "nieskonfigurowany" } } diff --git a/make_zip.py b/make_zip.py deleted file mode 100644 index a8a050b..0000000 --- a/make_zip.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import zipfile -import subprocess -from pathlib import Path - - -def run_git_command(args, repo_path: Path) -> bytes: - result = subprocess.run( - ["git", *args], - cwd=repo_path, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - return result.stdout - - -def get_files_to_archive(repo_path: Path) -> list[str]: - output = run_git_command( - ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], - repo_path, - ) - files = output.decode("utf-8", errors="surrogateescape").split("\0") - return [f for f in files if f] - - -def make_zip(repo_path: Path, output_zip: Path) -> None: - files = get_files_to_archive(repo_path) - - output_zip = output_zip.resolve() - if output_zip.exists(): - output_zip.unlink() - - with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for rel_path in files: - abs_path = repo_path / rel_path - - if not abs_path.exists(): - continue - - if abs_path.resolve() == output_zip: - continue - - zf.write(abs_path, arcname=rel_path) - - print(f"Created: {output_zip}") - print(f"Added files: {len(files)}") - - -def main(): - repo_path = Path.cwd() - - if len(sys.argv) > 1: - output_zip = Path(sys.argv[1]) - else: - output_zip = repo_path / f"{repo_path.name}.zip" - - try: - run_git_command(["rev-parse", "--show-toplevel"], repo_path) - except subprocess.CalledProcessError: - print("Error: this directory is not a Git repository.", file=sys.stderr) - sys.exit(1) - - make_zip(repo_path, output_zip) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 9b063c4..687fac6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -208,9 +208,13 @@ async fn bootstrap(State(state): State) -> Result, AppErro async fn build_bootstrap(state: &AppState) -> Result { let settings = state.settings.read().await.clone(); + let devices = state.db.list_devices()?; + let device_count = devices.len(); + let online_count = devices.iter().filter(|value| value.online).count(); + let simulator_count = devices.iter().filter(|value| value.simulated).count(); let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); Ok(json!({ - "devices": state.db.list_devices()?, + "devices": devices, "zones": state.db.list_zones()?, "groups": state.db.list_groups()?, "schedules": state.db.list_schedules()?, @@ -223,6 +227,13 @@ async fn build_bootstrap(state: &AppState) -> Result { "uptime_seconds": state.started.elapsed().as_secs(), "auth_required": !state.config.app_token.trim().is_empty(), "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), + "database": state.config.database.display().to_string(), + "device_count": device_count, + "online_count": online_count, + "simulator_count": simulator_count, + "bind": state.config.bind.to_string(), + "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, + "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, "gree_received_frames": received_frames_total, "gree_received_frames_by_device": received_frames_by_device, } @@ -240,7 +251,9 @@ async fn system_info(State(state): State) -> Result, AppEr "online_count": devices.iter().filter(|v| v.online).count(), "simulator_count": devices.iter().filter(|v| v.simulated).count(), "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), + "auth_required": !state.config.app_token.trim().is_empty(), "bind": state.config.bind.to_string(), + "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, "gree_received_frames": received_frames_total, "gree_received_frames_by_device": received_frames_by_device, diff --git a/web/app.js b/web/app.js index 162ba99..85dbe34 100644 --- a/web/app.js +++ b/web/app.js @@ -26,9 +26,9 @@ const app = { languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, - customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {}, - controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {}, - simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', + customChartSeries: [], savedCharts: [], chartZooms: {}, zoneControlSeq: {}, zoneTemperatureTimers: {}, + controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, + simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), }; try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } @@ -152,11 +152,38 @@ const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.hom function updateConnectionIndicator(status) { app.connectionStatus = status || 'connecting'; const node = $('#connectionLabel'); - if (!node) return; const label = tr(`status.${app.connectionStatus}`); - node.className = `connection-dot ${app.connectionStatus}`; - node.setAttribute('aria-label', label); - node.title = label; + if (node) { + node.className = `connection-dot ${app.connectionStatus}`; + node.setAttribute('aria-label', label); + node.title = label; + } + const banner = $('#connectionBanner'); + const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus); + document.body.classList.toggle('connection-lost', disconnected); + if (banner) { + banner.hidden = !disconnected; + const title = $('#connectionBannerTitle'); + const text = $('#connectionBannerText'); + if (title) title.textContent = label; + if (text) text.textContent = tr('status.offlineHint'); + } + renderSystemInfo(); +} + +function updateToolbarControls() { + const languageLabel = $('#languageLabel'); + if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase(); + const themeSelect = $('#themeSelect'); + const themeIcon = $('#themeIcon'); + const icons = {system:'◐',light:'☀',dark:'☾'}; + if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system; + if (themeSelect) { + themeSelect.value = app.theme; + const label = `${tr('controls.theme')}: ${tr(`theme.${app.theme}`)}`; + themeSelect.setAttribute('aria-label', label); + themeSelect.closest('.toolbar-picker')?.setAttribute('title', label); + } } function applyTheme() { @@ -166,8 +193,7 @@ function applyTheme() { document.documentElement.dataset.theme = resolved; const meta = $('#themeColorMeta'); if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515'; - const select = $('#themeSelect'); - if (select) select.value = app.theme; + updateToolbarControls(); drawCurrentChartIfVisible(); } @@ -180,7 +206,7 @@ function applyTranslations() { $$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); }); $$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); }); $('#languageSelect').value = app.language; - const themeSelect = $('#themeSelect'); if (themeSelect) { const icons={system:'◐',light:'☀',dark:'☾'}; [...themeSelect.options].forEach(o => { const key=`theme.${o.value}`; o.textContent=`${icons[o.value]||'◐'} ${tr(key)}`; }); themeSelect.value=app.theme; } + updateToolbarControls(); updateConnectionIndicator(app.connectionStatus); renderAll(); if (app.currentView === 'logs') loadLogs(); @@ -199,9 +225,10 @@ function renderLanguageOptions() { if (!select) return; select.innerHTML = app.languages.map(item => { const label = item.native_name || item.name || item.code.toUpperCase(); - const flag = ({pl:'🇵🇱',en:'🇬🇧'})[item.code] || '🌐'; return ``; + return ``; }).join(''); select.value = app.language; + updateToolbarControls(); } async function loadLanguages() { @@ -243,6 +270,12 @@ function setTheme(theme) { applyTheme(); } +function cycleTheme() { + const modes = ['system', 'light', 'dark']; + const index = modes.indexOf(app.theme); + setTheme(modes[(index + 1) % modes.length]); +} + async function api(path, options = {}) { const headers = new Headers(options.headers || {}); headers.set('Accept', 'application/json'); @@ -270,7 +303,7 @@ function toast(message, error = false) { const host = $('#toastStack'); if (!host) return; const item = document.createElement('div'); item.className = `toast-item ${error ? 'error' : 'success'}`; - item.innerHTML = `${error ? '!' : '✓'}
${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
`; + item.innerHTML = `${error ? '!' : '✓'}
${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
`; host.appendChild(item); requestAnimationFrame(() => item.classList.add('show')); const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); }; @@ -278,6 +311,291 @@ function toast(message, error = false) { item._timer = setTimeout(remove, error ? 5200 : 3600); } +const cleanFormSnapshots = new WeakMap(); + +function formSnapshot(form) { + if (!form) return ''; + const rows = []; + $$('input, select, textarea', form).forEach((field, index) => { + if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return; + const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`; + const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? ''); + rows.push([key, value, String(field.disabled)]); + }); + return JSON.stringify(rows); +} + +function markFormClean(form) { + if (!form) return; + cleanFormSnapshots.set(form, formSnapshot(form)); + form.classList.remove('has-unsaved-changes'); +} + +function updateDirtyIndicator(form) { + if (!form || !cleanFormSnapshots.has(form)) return; + form.classList.toggle('has-unsaved-changes', isFormDirty(form)); +} + +function isFormDirty(form) { + if (!form || !cleanFormSnapshots.has(form)) return false; + return cleanFormSnapshots.get(form) !== formSnapshot(form); +} + +function clearFormErrors(form) { + if (!form) return; + $$('.field-error, .form-error-summary', form).forEach(node => node.remove()); + $$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid')); + $$('.has-error', form).forEach(node => node.classList.remove('has-error')); +} + +function showFieldError(field, message) { + if (!field || !message) return; + field.setAttribute('aria-invalid', 'true'); + const label = field.closest('label'); + if (label) label.classList.add('has-error'); + const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null; + if (existing) { existing.textContent = message; return; } + const note = document.createElement('small'); + note.className = 'field-error'; + note.textContent = message; + field.insertAdjacentElement('afterend', note); +} + +function showFormError(form, message) { + if (!form || !message) return; + const summary = document.createElement('div'); + summary.className = 'form-error-summary'; + summary.setAttribute('role', 'alert'); + summary.textContent = message; + const anchor = $('.settings-save-bar', form) || $('.form-actions', form); + if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor); + else form.appendChild(summary); +} + +function validationMessageFor(field) { + if (field.validity?.valueMissing) return tr('validation.required'); + if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid'); + if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) { + return tr('validation.range', {min:field.min || '—', max:field.max || '—'}); + } + if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid'); + return field.validity?.valid === false ? tr('validation.invalid') : ''; +} + +function httpUrlValid(value) { + if (!String(value || '').trim()) return true; + try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; } +} + +function entityIdValid(value) { + return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim()); +} + +function validateDecimalField(form, name, min, max) { + const field = form?.elements?.[name]; + if (!field || field.disabled) return true; + const value = parseDecimal(field.value); + if (!Number.isFinite(value) || value < min || value > max) { + showFieldError(field, tr('validation.range', {min, max})); + return false; + } + return true; +} + +function validateForm(form) { + if (!form) return true; + clearFormErrors(form); + let valid = true; + $$('input, select, textarea', form).forEach(field => { + if (field.disabled || field.type === 'hidden' || field.type === 'file') return; + const message = validationMessageFor(field); + if (message) { showFieldError(field, message); valid = false; } + }); + + if (form.id === 'homeAssistantForm') { + const url = form.elements.ha_url; + if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } + ['ha_entity_id','ha_outdoor_entity_id'].forEach(name => { + const field = form.elements[name]; + if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; } + }); + } + + if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) { + const url = form.elements.influx_url; + if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; } + else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } + } + + if (form.id === 'zoneForm') { + const source = form.elements.sensor_source?.value; + const entity = form.elements.ha_entity_id; + if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) { + showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required')); + valid = false; + } + [ + ['cool_comfort_setpoint',8,30],['cool_sleep_setpoint',8,30],['cool_away_setpoint',8,30], + ['heat_comfort_setpoint',8,30],['heat_sleep_setpoint',8,30],['heat_away_setpoint',8,30], + ['hysteresis',0.1,5],['max_sensor_difference',0.1,20],['standby_offset_c',0.5,8] + ].forEach(([name,min,max]) => { if (!validateDecimalField(form,name,min,max)) valid = false; }); + } + + if (!valid) { + showFormError(form, tr('validation.fixFields')); + const first = $('[aria-invalid="true"]', form); + const pane = first?.closest?.('[data-settings-pane]'); + if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); + first?.focus({preventScroll:true}); + first?.scrollIntoView({behavior:'smooth', block:'center'}); + } + return valid; +} + +function apiErrorField(form, message) { + const text = String(message || '').toLowerCase(); + const candidates = []; + if (text.includes('influx')) candidates.push('influx_url'); + if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url'); + if (text.includes('entity')) candidates.push('ha_entity_id','ha_outdoor_entity_id'); + if (text.includes('device')) candidates.push('device_id','action_device_id','trigger_device_id'); + if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature','cool_comfort_setpoint','action_target_temperature'); + if (text.includes('name')) candidates.push('name','controller_id'); + if (text.includes('token')) candidates.push('ha_token','influx_token','pushover_app_token'); + if (text.includes('url')) candidates.push('ha_url','influx_url','slack_webhook_url','discord_webhook_url'); + return candidates.map(name => form?.elements?.[name]).find(Boolean) || null; +} + +function presentFormError(form, error) { + clearFormErrors(form); + const message = error?.message || tr('validation.invalid'); + const field = apiErrorField(form, message); + if (field) showFieldError(field, message); + showFormError(form, message); + if (field) { + const pane = field.closest?.('[data-settings-pane]'); + if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); + field.focus({preventScroll:true}); + field.scrollIntoView({behavior:'smooth', block:'center'}); + } + toast(message, true); +} + +function setFormBusy(form, busy, busyKey = 'actions.saving') { + if (!form) return; + const buttons = $$('button[type="submit"]', form); + if (busy) { + form.dataset.submitting = 'true'; + form.setAttribute('aria-busy', 'true'); + form.classList.add('is-saving'); + buttons.forEach(button => { + if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = `${esc(tr(busyKey))}`; + }); + } else { + delete form.dataset.submitting; + form.removeAttribute('aria-busy'); + form.classList.remove('is-saving'); + buttons.forEach(button => { + button.disabled = false; + if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; } + }); + } +} + +async function runFormTask(form, task, {busyKey='actions.saving', validate=true}={}) { + if (!form || form.dataset.submitting === 'true') return {ok:false, duplicate:true}; + if (validate && !validateForm(form)) return {ok:false, validation:true}; + clearFormErrors(form); + setFormBusy(form, true, busyKey); + try { + const value = await task(); + markFormClean(form); + return {ok:true, value}; + } catch (error) { + presentFormError(form, error); + return {ok:false, error}; + } finally { + setFormBusy(form, false); + } +} + +function restoreTrackedForm(form) { + if (!form) return; + if (form.id === 'settingsForm') renderSettings(); + else if (form.id === 'nightModeForm') renderNightSettings(); + else if (form.id === 'homeAssistantForm') { + app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; + renderHomeAssistantSettings(); + } else markFormClean(form); +} + +function confirmDiscardForm(form) { + if (!isFormDirty(form)) return true; + if (!confirm(tr('confirm.discardChanges'))) return false; + restoreTrackedForm(form); + return true; +} + +function activeDirtySettingsForm() { + const active = $('.view.active'); + if (!active) return null; + const form = $('form.settings-form', active); + return form && isFormDirty(form) ? form : null; +} + +function canLeaveCurrentView(nextName) { + if (nextName === app.currentView) return true; + const form = activeDirtySettingsForm(); + return !form || confirmDiscardForm(form); +} + +function requestDialogClose(dialog) { + if (!dialog) return true; + const form = $('form', dialog); + if (form && !confirmDiscardForm(form)) return false; + dialog.close(); + return true; +} + +function setupFormUx() { + $$('form').forEach(form => { + form.noValidate = true; + if (!cleanFormSnapshots.has(form)) markFormClean(form); + }); +} + +document.addEventListener('input', event => { + const field = event.target.closest?.('input, select, textarea'); + const form = field?.form; + if (!field || !form) return; + if (field.getAttribute('aria-invalid') === 'true') { + field.removeAttribute('aria-invalid'); + field.closest('label')?.classList.remove('has-error'); + if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove(); + $('.form-error-summary', form)?.remove(); + } + updateDirtyIndicator(form); +}); +document.addEventListener('change', event => updateDirtyIndicator(event.target?.form)); + +window.addEventListener('beforeunload', event => { + const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form)); + if (!dirty) return; + event.preventDefault(); + event.returnValue = ''; +}); + +document.addEventListener('cancel', event => { + const dialog = event.target.closest?.('dialog'); + if (!dialog) return; + const form = $('form', dialog); + if (!form || !isFormDirty(form)) return; + event.preventDefault(); + if (confirmDiscardForm(form)) dialog.close(); +}, true); + function showTokenDialog() { const dialog = $('#tokenDialog'); if (!dialog.open) dialog.showModal(); @@ -297,6 +615,7 @@ async function loadBootstrap(showMessage = false) { app.settings = data.settings || null; app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; app.system = data.system || {}; + app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; renderAll(); loadControlPlan(); @@ -326,6 +645,7 @@ function renderAll() { renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); + renderSimulationModeBanner(); renderDebugOverlay(); } @@ -347,6 +667,7 @@ function renderSummary() { [tr('dashboard.metricActive'), active], [tr('dashboard.metricDemand'), demand], ].map(([label,value]) => `
${esc(label)}${esc(value)}
`).join(''); + renderSystemInfo(); } function renderHouseClimate() { @@ -395,7 +716,7 @@ function renderControlPlan() { const allZones = plan.zones || []; const houseEvents = (plan.next_events || []).slice(0, 3); - const house = `
${esc(tr('plan.house'))}

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

${esc(plan.control_strategy || 'setpoint')}

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

    ${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; + const house = `
${esc(tr('plan.house'))}

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

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

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

    ${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; const groupCards = (app.groups || []).map(group => { const memberIds = new Set(group.zone_ids || []); @@ -628,7 +949,7 @@ function renderSimulationPage() { const demandingZones = enabledZones.filter(zone => zone.demand); const nightOn = !!plan.night_mode_active; summaryHost.innerHTML = [ - {label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})}, + {label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')})}, {label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`}, {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`}, @@ -647,7 +968,7 @@ function renderSimulationPage() { const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `
${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}

${esc(title)}

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

${esc(meta)}

` : ''}
`; const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28; - nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')})); + nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:(plan.control_strategy || 'setpoint')==='setpoint'?tr('plan.strategySetpoint'):(plan.control_strategy || 'setpoint')}),badge:tr('simulation.house')})); nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''})); zones.forEach((planZone, index) => { @@ -672,7 +993,7 @@ function renderSimulationPage() { nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)})); nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge})); const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : ''; - nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?'RUN':'IDLE'),badgeClass:sim.demand?'active':''})); + nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?tr('simulation.running'):tr('simulation.idle')),badgeClass:sim.demand?'active':''})); nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'})); links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link')); @@ -1109,10 +1430,10 @@ function renderSettings() { const n=app.settings.notifications||{}; form.notifications_enabled.checked=!!n.enabled; form.notifications_mode.value=n.mode||'problems'; form.notifications_provider.value=n.provider||'pushover'; form.pushover_app_token.value=''; form.pushover_user_key.value=''; form.slack_webhook_url.value=''; form.discord_webhook_url.value=''; - form.pushover_app_token.placeholder=n.pushover_configured?'Saved - leave empty to keep':'Application token'; - form.pushover_user_key.placeholder=n.pushover_configured?'Saved - leave empty to keep':'User/group key'; - form.slack_webhook_url.placeholder=n.slack_configured?'Saved - leave empty to keep':'https://hooks.slack.com/services/…'; - form.discord_webhook_url.placeholder=n.discord_configured?'Saved - leave empty to keep':'https://discord.com/api/webhooks/…'; + form.pushover_app_token.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.applicationTokenPlaceholder'); + form.pushover_user_key.placeholder=n.pushover_configured?tr('settings.secretSaved'):tr('notifications.userKeyPlaceholder'); + form.slack_webhook_url.placeholder=n.slack_configured?tr('settings.secretSaved'):'https://hooks.slack.com/services/…'; + form.discord_webhook_url.placeholder=n.discord_configured?tr('settings.secretSaved'):'https://discord.com/api/webhooks/…'; form.notification_cooldown_seconds.value=n.cooldown_seconds||300; form.notification_failure_threshold.value=n.communication_failure_threshold||3; form.notification_target_timeout.value=n.target_timeout_minutes||60; const alerts=n.alert_types||{}; form.notification_alert_stale_sensor.checked=alerts.stale_sensor!==false; @@ -1127,7 +1448,65 @@ function renderSettings() { updateInfluxFields(); renderLogRetention(); renderGreeFrameStats(); - $('#systemInfo').innerHTML = `

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

${esc(tr('settings.version'))}: ${esc(app.system.version || '—')}
${esc(tr('settings.uptime'))}: ${esc(formatDuration(app.system.uptime_seconds || 0))}
${esc(tr('settings.apiAuth'))}: ${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}
`; + renderSystemInfo(); + renderSimulationModeBanner(); + setSettingsTab(app.settingsTab); + markFormClean(form); +} + +function renderSimulationModeBanner() { + const banner = $('#simulationModeBanner'); + if (!banner) return; + const enabled = !!app.settings?.simulator_enabled; + banner.hidden = !enabled; + document.body.classList.toggle('simulation-mode-enabled', enabled); +} + +function setSettingsTab(tab) { + app.settingsTab = tab === 'gree' ? 'gree' : 'app'; + $$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; }); + $$('[data-settings-tab]').forEach(button => { + const active = button.dataset.settingsTab === app.settingsTab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); +} + +function renderSystemInfo() { + const host = $('#systemInfo'); + if (!host) return; + const devices = app.devices || []; + const online = devices.filter(device => device.online).length; + const simulated = devices.filter(device => device.simulated).length; + const elapsed = Math.max(0, Math.floor((Date.now() - Number(app.systemSnapshotAt || Date.now())) / 1000)); + const uptime = Number(app.system?.uptime_seconds || 0) + elapsed; + const dbPath = String(app.system?.database || ''); + const database = dbPath ? dbPath.split(/[\\/]/).filter(Boolean).pop() : '—'; + const ready = !!app.system?.control_ready; + const runtimeSimulation = !!app.settings?.simulator_enabled; + const items = [ + [tr('settings.version'), app.system?.version || '—', 'version'], + [tr('settings.uptime'), formatDuration(uptime), 'uptime'], + [tr('settings.controlEngine'), ready ? tr('settings.ready') : tr('settings.syncing'), ready ? 'ok' : 'warn'], + [tr('settings.websocket'), tr(`status.${app.connectionStatus}`), app.connectionStatus === 'connected' ? 'ok' : 'warn'], + [tr('settings.runtimeMode'), runtimeSimulation ? tr('settings.runtimeSimulation') : tr('settings.runtimeLive'), runtimeSimulation ? 'warn' : 'ok'], + [tr('settings.apiAuth'), app.system?.auth_required ? tr('settings.enabled') : tr('settings.disabled'), app.system?.auth_required ? 'ok' : 'warn'], + [tr('settings.devicesConfigured'), devices.length, 'devices'], + [tr('settings.devicesOnline'), `${online}/${devices.length}`, online === devices.length && devices.length ? 'ok' : 'neutral'], + [tr('settings.simulatedDevices'), simulated, simulated ? 'warn' : 'neutral'], + [tr('settings.pollCycle'), `${Number(app.settings?.poll_interval_seconds || 0)} s`, 'neutral'], + [tr('settings.zoneCycle'), `${Number(app.settings?.zone_interval_seconds || 0)} s`, 'neutral'], + [tr('settings.homeAssistantStatus'), app.settings?.home_assistant?.url ? tr('settings.configured') : tr('settings.notConfigured'), app.settings?.home_assistant?.url ? 'ok' : 'neutral'], + [tr('settings.influxStatus'), app.settings?.influxdb?.enabled ? `v${app.settings?.influxdb?.version || '2'} · ${tr('settings.enabled')}` : tr('settings.disabled'), app.settings?.influxdb?.enabled ? 'ok' : 'neutral'], + [tr('settings.notificationsStatus'), app.settings?.notifications?.enabled ? tr('settings.enabled') : tr('settings.disabled'), app.settings?.notifications?.enabled ? 'ok' : 'neutral'], + [tr('settings.debugStatus'), app.settings?.debug?.overlay_enabled ? tr('settings.enabled') : tr('settings.disabled'), app.settings?.debug?.overlay_enabled ? 'warn' : 'neutral'], + [tr('settings.receivedFrames'), Number(app.system?.gree_received_frames || 0).toLocaleString(locale()), 'traffic'], + [tr('settings.httpBind'), app.system?.bind || '—', 'mono'], + [tr('settings.greeInterface'), app.system?.gree_interface || 'auto', 'mono'], + [tr('settings.database'), database, 'mono'], + [tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'], + ]; + host.innerHTML = `
${esc(tr('settings.systemState'))}

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

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

${esc(ready && app.connectionStatus === 'connected' ? tr('settings.healthy') : tr('settings.attention'))}
${items.map(([label,value,tone]) => `
${esc(label)}${esc(value)}
`).join('')}
`; } function renderGreeFrameStats() { @@ -1152,6 +1531,7 @@ function renderNightSettings() { form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1); form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false; form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false; + markFormClean(form); } function renderHomeAssistantSettings() { @@ -1168,6 +1548,7 @@ function renderHomeAssistantSettings() { form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; renderSensorAliases(); renderAccessTokens(); + markFormClean(form); } function updateInfluxFields() { @@ -1189,13 +1570,25 @@ function renderDebugOverlay() { const enabled = !!app.settings?.debug?.overlay_enabled; overlay.hidden = !enabled; if (!enabled) return; + if (!['all','requests','gree'].includes(app.debugFilter)) app.debugFilter = 'all'; const status = $('#debugOverlayStatus'); if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly'); + $$('[data-debug-filter]', overlay).forEach(button => { + const active = button.dataset.debugFilter === app.debugFilter; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + }); const host = $('#debugOverlayLines'); if (!host) return; - host.innerHTML = app.debugLines.length ? app.debugLines.slice(-120).map(line => { + const visible = app.debugLines.filter(line => { + if (app.debugFilter === 'gree') return line.source === 'GREE'; + if (app.debugFilter === 'requests') return line.source === 'HTTP'; + return true; + }).slice(-120); + host.innerHTML = visible.length ? visible.map(line => { const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`; - return `
${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
`; - }).join('') : `
${esc(tr('debug.empty'))}
`; + const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : 'api'; + return `
${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
`; + }).join('') : `
${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : 'debug.empty'))}
`; host.scrollTop = host.scrollHeight; } @@ -1214,6 +1607,8 @@ function formatDuration(seconds) { return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; } +setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000); + const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', groups:'/groups', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'}; const HISTORY_TABS = ['overview','zones','devices','sensors','custom']; @@ -1254,6 +1649,7 @@ function setDashboardTab(tab, {scroll=true}={}) { } function showView(name, {push=true, scroll=true}={}) { + if (!canLeaveCurrentView(name)) return false; app.currentView = name; $$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name)); $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name)))); @@ -1262,6 +1658,7 @@ function showView(name, {push=true, scroll=true}={}) { if (name === 'dashboard') setDashboardTab(app.dashboardTab, {scroll:false}); if (name === 'history') { renderHistoryNavigation(); loadHistory(); } if (name === 'logs') loadLogs(); + return true; } function showHistoryTab(tab, {push=true, load=true}={}) { @@ -1290,11 +1687,13 @@ function applyRouteFromLocation() { const hours = params.get('hours'); if (hours && ['6','24','168','720','2160','8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart')); - showView('history', {push:false, scroll:false}); + const previousView = app.currentView; + if (!showView('history', {push:false, scroll:false})) updateBrowserUrl(VIEW_ROUTES[previousView] || '/dashboard', true); return; } const reverse = Object.entries(VIEW_ROUTES).find(([,path]) => path === `/${first}`); - showView(reverse?.[0] || 'dashboard', {push:false, scroll:false}); + const previousView = app.currentView; + if (!showView(reverse?.[0] || 'dashboard', {push:false, scroll:false})) updateBrowserUrl(previousView === 'history' ? currentHistoryPath() : (VIEW_ROUTES[previousView] || '/dashboard'), true); } async function sendDeviceCommand(id, command) { @@ -1371,7 +1770,11 @@ async function deleteEntity(type, id, labelKey) { function openDialog(id) { fillSelects(); const dialog = document.getElementById(id); - if (dialog && !dialog.open) dialog.showModal(); + if (dialog && !dialog.open) { + const form = $('form', dialog); + if (form) { clearFormErrors(form); markFormClean(form); } + dialog.showModal(); + } } function updateZoneSensorFields() { @@ -1623,7 +2026,9 @@ function timeLabel(ts, hours) { function prepareCanvas(canvas, height) { const rect = canvas.getBoundingClientRect(); - const width = Math.max(720, Math.floor(rect.width || 720)); + const wrapWidth = Math.floor(canvas.parentElement?.clientWidth || rect.width || 720); + const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4); + const width = Math.max(720, Math.floor(Math.max(720, wrapWidth) * zoom)); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; @@ -1637,6 +2042,106 @@ 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); + const wrap=canvas.parentElement; + const tooltip=wrap?.querySelector('.chart-tooltip'); + const line=wrap?.querySelector('.chart-hover-line'); + if(tooltip) tooltip.hidden=true; + if(line) line.hidden=true; + canvas.onpointermove=null; canvas.onpointerleave=null; canvas.onpointerdown=null; canvas.onkeydown=null; +} + +function chartPreciseTime(timestamp) { + return new Intl.DateTimeFormat(locale(), { + year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' + }).format(new Date(timestamp)); +} + +function nearestPoint(points, targetTs) { + if (!points.length) return null; + let lo=0, hi=points.length-1; + while(lo0?points[lo-1]:null; + if(!before) return after; + return Math.abs(before.ts-targetTs) <= Math.abs(after.ts-targetTs) ? before : after; +} + +function formatChartTooltipValue(item, point, binaryLabels) { + if (!point || !Number.isFinite(point.value)) return '—'; + if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row)); + const formatted = Number(point.value).toLocaleString(locale(), {minimumFractionDigits:1, maximumFractionDigits:2}); + return binaryLabels ? formatted : `${formatted} °C`; +} + +function bindChartTooltip(canvas, series, sortedRows, geometry) { + const wrap=canvas?.parentElement; + const tooltip=wrap?.querySelector('.chart-tooltip'); + const line=wrap?.querySelector('.chart-hover-line'); + if(!canvas || !wrap || !tooltip || !line) return; + + const {pad,width,height,firstTs,lastTs,binaryLabels}=geometry; + const plotWidth=width-pad.left-pad.right; + const timestamps=[...new Set(sortedRows.map(row=>new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a,b)=>a-b); + const timestampPoints=timestamps.map(ts=>({ts,value:ts})); + const pointsBySeries=series.map(item=>({ + item, + points:sortedRows.map(row=>({row,ts:new Date(row.timestamp).getTime(),value:item.value(row)})) + .filter(point=>Number.isFinite(point.ts)&&Number.isFinite(point.value)) + .sort((a,b)=>a.ts-b.ts) + })); + + const hide=()=>{ tooltip.hidden=true; line.hidden=true; }; + const showAtTimestamp=(targetTs)=>{ + if(!timestamps.length) return hide(); + const snapped=nearestPoint(timestampPoints,targetTs)?.ts; + if(!Number.isFinite(snapped)) return hide(); + const px=pad.left + (snapped-firstTs)/Math.max(1,lastTs-firstTs)*plotWidth; + const values=pointsBySeries.map(({item,points})=>({item,point:nearestPoint(points,snapped)})).filter(entry=>entry.point); + if(!values.length) return hide(); + + $$('.chart-tooltip').forEach(node=>{ if(node!==tooltip) node.hidden=true; }); + $$('.chart-hover-line').forEach(node=>{ if(node!==line) node.hidden=true; }); + tooltip.innerHTML=`${esc(chartPreciseTime(snapped))}
${values.map(({item,point})=>{ + const sampleTime=Math.abs(point.ts-snapped)>1000?`${esc(chartPreciseTime(point.ts))}`:''; + return `
${esc(item.label)}${esc(formatChartTooltipValue(item,point,binaryLabels))}${sampleTime}
`; + }).join('')}
`; + tooltip.hidden=false; + line.hidden=false; + line.style.left=`${px}px`; + line.style.top=`${pad.top}px`; + line.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`; + + const minLeft=wrap.scrollLeft+8; + const maxLeft=wrap.scrollLeft+wrap.clientWidth-tooltip.offsetWidth-8; + let tooltipLeft=px+12; + if(tooltipLeft+tooltip.offsetWidth>wrap.scrollLeft+wrap.clientWidth-8) tooltipLeft=px-tooltip.offsetWidth-12; + tooltip.style.left=`${Math.max(minLeft,Math.min(tooltipLeft,Math.max(minLeft,maxLeft)))}px`; + tooltip.style.top=`${pad.top+8}px`; + canvas.dataset.chartHoverTs=String(snapped); + }; + + const showFromClientX=(clientX)=>{ + const rect=canvas.getBoundingClientRect(); + const localX=clamp(clientX-rect.left,pad.left,width-pad.right); + const targetTs=firstTs + ((localX-pad.left)/Math.max(1,plotWidth))*(lastTs-firstTs); + showAtTimestamp(targetTs); + }; + + canvas.onpointermove=event=>showFromClientX(event.clientX); + canvas.onpointerdown=event=>showFromClientX(event.clientX); + canvas.onpointerleave=event=>{ if(event.pointerType!=='touch') hide(); }; + canvas.onkeydown=event=>{ + if(!['ArrowLeft','ArrowRight'].includes(event.key) || !timestamps.length) return; + event.preventDefault(); + const current=Number(canvas.dataset.chartHoverTs); + let index=Number.isFinite(current)?timestamps.findIndex(ts=>ts===current):-1; + if(index<0) index=event.key==='ArrowRight'?0:timestamps.length-1; + else index=clamp(index+(event.key==='ArrowRight'?1:-1),0,timestamps.length-1); + showAtTimestamp(timestamps[index]); + }; } function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) { @@ -1679,6 +2184,7 @@ function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValu }); ctx.stroke(); ctx.setLineDash([]); }); + bindChartTooltip(canvas,series,sortedRows,{pad,width,height,firstTs,lastTs,binaryLabels}); } function renderLegend(host, series) { @@ -1687,7 +2193,9 @@ function renderLegend(host, series) { } function historyChartMarkup(id, title, hint, compact=false) { - return `

${esc(title)}

${esc(hint || '')}

`; + const zoom = clamp(Number(app.chartZooms[id] || 1), 1, 4); + const percent = Math.round(zoom * 100); + return `

${esc(title)}

${esc(hint || '')}

${esc(tr('history.hoverHint'))}
`; } function historySeriesColor(index) { @@ -1749,9 +2257,9 @@ function renderZoneHistory() { ]; drawLineChart($('#zoneTemperatureChart'),temperatureSeries,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),temperatureSeries); const operationSeries=[ - {label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed)}, - {label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5}, - {label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5}, + {label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed),tooltipValue:value=>fanLabel(Math.round(value))}, + {label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5,tooltipValue:(_,row)=>row.demand?tr('common.active'):tr('common.disabled')}, + {label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5,tooltipValue:(_,row)=>row.power?tr('common.on'):tr('common.off')}, ]; drawLineChart($('#zoneOperationChart'),operationSeries,rows,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#zoneOperationChartLegend'),operationSeries); } @@ -1830,7 +2338,7 @@ function renderCustomBuilder() { const selected=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); host.innerHTML=`

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

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

-
${selected.length?selected.map((item,index)=>`${esc(item.label)}`).join(''):`${esc(tr('history.noCustomSeries'))}`}
+
${selected.length?selected.map((item,index)=>`${esc(item.label)}`).join(''):`${esc(tr('history.noCustomSeries'))}`}
${app.savedCharts.length?app.savedCharts.map(item=>`
`).join(''):`${esc(tr('history.noSavedCharts'))}`}
`; @@ -1917,7 +2425,7 @@ function connectWebSocket() { try { const message = JSON.parse(event.data); if (message.event === 'bootstrap') { - const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.systemSnapshotAt=Date.now(); app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; } const data = message.data || {}; if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } @@ -1927,9 +2435,17 @@ function connectWebSocket() { else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } - else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); } + else if (message.event === 'settings.updated') { + const settingsDirty=isFormDirty($('#settingsForm')), nightDirty=isFormDirty($('#nightModeForm')), haDirty=isFormDirty($('#homeAssistantForm')); + app.settings=data; + if (!haDirty) app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; + if (!settingsDirty) renderSettings(); + if (!nightDirty) renderNightSettings(); + if (!haDirty) renderHomeAssistantSettings(); + renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); + } else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } - else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } + else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); } else if (message.event === 'gree.frame_received') { app.system = app.system || {}; @@ -1937,6 +2453,7 @@ function connectWebSocket() { app.system.gree_received_frames_by_device = {...(app.system.gree_received_frames_by_device || {})}; if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0); renderGreeFrameStats(); + renderSystemInfo(); } else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } @@ -1948,6 +2465,8 @@ function connectWebSocket() { document.addEventListener('click', async event => { const button = event.target.closest('button'); if (!button) return; + if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; } + if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; } if (button.dataset.dashboardTab) { setDashboardTab(button.dataset.dashboardTab); return; } if (button.dataset.nav) { if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav); @@ -1970,7 +2489,26 @@ document.addEventListener('click', async event => { openDialog(button.dataset.open); return; } - if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; } + if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; } + if (button.dataset.chartZoom) { + const id = button.dataset.chartId; + const canvas = id ? document.getElementById(id) : null; + const wrap = canvas?.parentElement; + const oldWidth = canvas?.getBoundingClientRect().width || 1; + const centerRatio = wrap ? (wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth : .5; + const current = clamp(Number(app.chartZooms[id] || 1), 1, 4); + const next = button.dataset.chartZoom === 'in' ? Math.min(4, current + .5) : button.dataset.chartZoom === 'out' ? Math.max(1, current - .5) : 1; + app.chartZooms[id] = next; + renderHistoryPage(); + requestAnimationFrame(() => { + const nextCanvas = document.getElementById(id); + const nextWrap = nextCanvas?.parentElement; + if (!nextCanvas || !nextWrap) return; + const nextWidth = nextCanvas.getBoundingClientRect().width || 1; + nextWrap.scrollLeft = Math.max(0, centerRatio * nextWidth - nextWrap.clientWidth / 2); + }); + return; + } if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; } if (button.dataset.historyAction) { await handleHistoryAction(button); return; } if (button.dataset.scheduleTemplate) { @@ -2003,6 +2541,7 @@ document.addEventListener('click', async event => { } if (action === 'house-power') { const power = button.dataset.value === 'true'; + if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return; try { button.disabled = true; const result = await api('/api/house/power',{method:'POST',body:{power}}); @@ -2070,7 +2609,20 @@ document.addEventListener('click', async event => { } }); -$('#refreshButton').addEventListener('click', () => loadBootstrap(true)); +$('#refreshButton').addEventListener('click', () => { + const form = activeDirtySettingsForm(); + if (form && !confirmDiscardForm(form)) return; + loadBootstrap(true); +}); +$('#connectionRetry')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + if (app.ws) { try { app.ws.close(); } catch (_) {} app.ws = null; } + await loadBootstrap(false); + connectWebSocket(); + } finally { button.disabled = false; } +}); $('#discoverButton').addEventListener('click', () => { const form=$('#discoverForm'); form.reset(); form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000))); @@ -2080,7 +2632,7 @@ $('#historyRefresh').addEventListener('click', loadHistory); $('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); }); $('#logsRefresh').addEventListener('click', loadLogs); $('#languageSelect').addEventListener('change', event => setLanguage(event.target.value)); -$('#themeSelect').addEventListener('change', event => setTheme(event.target.value)); +$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value)); $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs); $('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields); $('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); @@ -2129,51 +2681,59 @@ $('#copyZoneSettings')?.addEventListener('click',()=>{ f.elements.enabled.checked=protectedValues.enabled; f.dataset.editingZoneId=targetId; updateZoneSensorFields(); + updateDirtyIndicator(f); toast(tr('zones.copiedFrom',{name:source.name})); }); -$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; }); +$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:tr('automations.presetHot'),trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:tr('automations.presetCold'),trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:tr('automations.presetMorning'),trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:tr('automations.presetNightOff'),trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; }); -$('#tokenForm').addEventListener('submit', event => { - event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim(); - localStorage.setItem('gree_controller_token', app.token); if (app.ws) app.ws.close(); loadBootstrap(); +$('#tokenForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + if (!validateForm(form) || form.dataset.submitting === 'true') return; + setFormBusy(form, true, 'auth.connecting'); + app.token = new FormData(form).get('token').trim(); + localStorage.setItem('gree_controller_token', app.token); + if (app.ws) app.ws.close(); + try { await loadBootstrap(); } + finally { setFormBusy(form, false); } }); $('#discoverForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - const submit=form.querySelector('button[type=submit]'); submit.disabled=true; submit.textContent=tr('actions.discovering'); - try { + await runFormTask(form, async () => { const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}}); form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []); - } catch(error){toast(error.message,true);} finally {submit.disabled=false;submit.textContent=tr('actions.discover');} + }, {busyKey:'actions.discovering'}); }); $('#discoveryNamesForm').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; const inputs = $$('input[data-device-id]', form); - try { + await runFormTask(form, async () => { const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}}))); updated.forEach(updateDevice); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); - } catch (error) { toast(error.message, true); } + }); }); $('#renameDeviceForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); - try { + await runFormTask(form, async () => { const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}}); updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); - } catch(error){toast(error.message,true);} + }); }); $('#deviceForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form)); data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked; - try { await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added')); } - catch(error){toast(error.message,true);} + await runFormTask(form, async () => { + await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added')); + }); }); $('#zoneForm').addEventListener('submit', async event => { @@ -2187,8 +2747,9 @@ $('#zoneForm').addEventListener('submit', async event => { min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked, sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference),sensor_stale_after_seconds:Number(raw.sensor_stale_after_seconds||300),revision:raw.revision?Number(raw.revision):null }; - try { await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } - catch(error){toast(error.message,true);} + await runFormTask(form, async () => { + await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); }); $('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields); @@ -2244,13 +2805,11 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => { const safety = Number(raw.max_duration_minutes); if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety; } - try { - $('#temporaryThermostatSubmit').disabled = true; + await runFormTask(form, async () => { const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone; form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); - } catch (error) { toast(error.message, true); } - finally { $('#temporaryThermostatSubmit').disabled = false; } + }, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'}); }); $('#groupForm')?.addEventListener('submit', async event => { @@ -2261,18 +2820,19 @@ $('#groupForm')?.addEventListener('submit', async event => { if (!zone_ids.length) return toast(tr('groups.chooseMember'), true); const existing=id ? app.groups.find(group => group.id===id) : null; const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false}; - try { + await runFormTask(form, async () => { await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); - } catch(error){toast(error.message,true);} + }); }); $('#scheduleForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value)); const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:parseDecimal(raw.setpoint||23)}; - try { await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } - catch(error){toast(error.message,true);} + await runFormTask(form, async () => { + await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); }); $('#automationForm').addEventListener('submit', async event => { @@ -2280,8 +2840,9 @@ $('#automationForm').addEventListener('submit', async event => { const groupTarget=raw.action_target_kind==='group'; const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature); const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)}; - try { await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } - catch(error){toast(error.message,true);} + await runFormTask(form, async () => { + await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); + }); }); function currentSettingsBody() { @@ -2432,6 +2993,8 @@ async function saveRuntimeSettings(body, notify = true) { renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); + renderSimulationModeBanner(); + renderSystemInfo(); renderDebugOverlay(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); @@ -2440,21 +3003,18 @@ async function saveRuntimeSettings(body, notify = true) { } $('#settingsForm').addEventListener('submit', async event => { - event.preventDefault(); - try { await saveRuntimeSettings(settingsBodyFromForm(event.currentTarget), true); } - catch(error) { toast(error.message, true); } + event.preventDefault(); const form = event.currentTarget; + await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true)); }); $('#nightModeForm')?.addEventListener('submit', async event => { - event.preventDefault(); - try { await saveRuntimeSettings(nightSettingsBodyFromForm(event.currentTarget), true); } - catch(error) { toast(error.message, true); } + event.preventDefault(); const form = event.currentTarget; + await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true)); }); $('#homeAssistantForm')?.addEventListener('submit', async event => { - event.preventDefault(); - try { await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(event.currentTarget), true); } - catch(error) { toast(error.message, true); } + event.preventDefault(); const form = event.currentTarget; + await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true)); }); $('#addSensorAlias')?.addEventListener('click', () => { @@ -2464,18 +3024,21 @@ $('#addSensorAlias')?.addEventListener('click', () => { app.sensorAliases[entity] = alias; entityInput.value = ''; aliasInput.value = ''; renderSensorAliases(); renderHistoryNavigation(); + updateDirtyIndicator($('#homeAssistantForm')); }); document.addEventListener('input', event => { const input = event.target.closest('[data-sensor-alias]'); if (!input) return; const entity = input.dataset.sensorAlias, alias = input.value.trim(); if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity]; + updateDirtyIndicator($('#homeAssistantForm')); }); document.addEventListener('click', event => { const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return; delete app.sensorAliases[button.dataset.clearSensorAlias]; renderSensorAliases(); renderHistoryNavigation(); + updateDirtyIndicator($('#homeAssistantForm')); }); $('#saveLogRetention')?.addEventListener('click', async () => { @@ -2514,14 +3077,20 @@ $('#copyAccessToken').addEventListener('click', async () => { } }); -$('#haTest').addEventListener('click', async () => { - const form=$('#homeAssistantForm'); +$('#haTest').addEventListener('click', async event => { + const form=$('#homeAssistantForm'), button=event.currentTarget; + if (!validateForm(form)) return; + button.disabled = true; + const idle = button.textContent; + button.textContent = tr('settings.testingHa'); try { await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false); const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null; const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}}); + markFormClean(form); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); - } catch(error){toast(error.message,true);} + } catch(error){presentFormError(form,error);} + finally { button.disabled=false; button.textContent=idle; } }); $('#simulationRefresh')?.addEventListener('click', async () => { @@ -2574,6 +3143,7 @@ async function startApplication() { applyTheme(); await loadLanguages(); applyTranslations(); + setupFormUx(); updateZoneSensorFields(); updateSchedulePresetField(); await loadBootstrap(); diff --git a/web/index.html b/web/index.html index 84d9927..2aa751e 100644 --- a/web/index.html +++ b/web/index.html @@ -18,15 +18,32 @@
0/0
- - - +
+ + + +
+
+ + + +
+ +
+ + @@ -158,7 +175,7 @@
-
+
Save changes made in the blocks above.
-
Diagnostics

Events

-
Log retentionOld event rows are removed automatically during maintenance.
+
Log retentionOld event rows are removed automatically during maintenance.
@@ -557,7 +574,17 @@
diff --git a/web/styles.css b/web/styles.css index 0094f88..0217584 100644 --- a/web/styles.css +++ b/web/styles.css @@ -203,8 +203,21 @@ input:focus, select:focus { border-color: var(--accent); outline: 2px solid colo .influx-fields[hidden] { display: none; } .influx-fields label:last-child:nth-child(odd) { grid-column: 1/-1; } .backup-actions { align-items: center; } -.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; } -.system-panel strong { color: var(--text); } +.system-panel { display:grid; gap:16px; margin-top:14px; padding:18px; } +.system-panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } +.system-panel-head h3 { margin:4px 0 5px; font-size:20px; } +.system-panel-head p { max-width:700px; margin:0; color:var(--muted); font-size:12px; line-height:1.5; } +.system-health { display:inline-flex; align-items:center; min-height:30px; padding:6px 10px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:var(--surface-muted); font-size:10px; font-weight:800; white-space:nowrap; } +.system-health.ok { border-color:var(--accent-border); color:var(--accent); background:var(--accent-soft); } +.system-health.warn { border-color:color-mix(in srgb,var(--warning) 35%,var(--line)); color:var(--warning); background:var(--warning-soft); } +.system-status-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); gap:9px; } +.system-status-item { min-width:0; padding:12px; border:1px solid var(--line); border-radius:14px; background:var(--surface-muted); } +.system-status-item small,.system-status-item strong { display:block; } +.system-status-item small { margin-bottom:5px; color:var(--muted); font-size:10px; } +.system-status-item strong { overflow:hidden; color:var(--text); font-size:13px; text-overflow:ellipsis; white-space:nowrap; } +.system-status-item.ok { border-color:color-mix(in srgb,var(--accent) 25%,var(--line)); } +.system-status-item.warn { border-color:color-mix(in srgb,var(--warning) 28%,var(--line)); background:color-mix(in srgb,var(--warning-soft) 52%,var(--surface-muted)); } +.system-status-item.mono strong { font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace; font-size:11px; } .gree-frame-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; } .gree-frame-stat { display:flex; align-items:center; justify-content:space-between; gap:12px; min-width:0; padding:12px 14px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); } .gree-frame-stat small { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; } @@ -464,22 +477,38 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; } .debug-overlay { position: fixed; z-index: 110; right: 18px; bottom: 92px; width: min(680px, calc(100vw - 36px)); max-height: min(46vh, 440px); overflow: hidden; border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 18px; background: color-mix(in srgb, var(--surface) 96%, transparent); box-shadow: 0 18px 60px rgba(0,0,0,.22); backdrop-filter: blur(18px); } .debug-overlay[hidden] { display: none; } -.debug-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); } -.debug-overlay-head > div { display: grid; gap: 2px; } -.debug-overlay-head strong { font-size: 12px; } -.debug-overlay-head small { color: var(--muted); font-size: 10px; } -.debug-overlay-head button { padding: 6px 9px; color: var(--muted); background: var(--surface-muted); font-size: 10px; } -.debug-overlay-lines { max-height: min(38vh, 360px); overflow: auto; padding: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } -.debug-line { display: grid; grid-template-columns: 70px 46px 1fr; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); font-size: 10px; line-height: 1.35; } -.debug-line time, .debug-line b { color: var(--muted); font-weight: 600; } -.debug-line span { overflow-wrap: anywhere; white-space: pre-wrap; } +.debug-overlay-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border-bottom:1px solid var(--line); } +.debug-overlay-title { display:grid; flex:0 1 auto; gap:2px; min-width:90px; } +.debug-overlay-head strong { font-size:12px; } +.debug-overlay-head small { color:var(--muted); font-size:10px; } +.debug-overlay-actions { display:flex; align-items:center; justify-content:flex-end; gap:7px; min-width:0; } +.debug-source-tabs { display:flex; align-items:center; gap:3px; padding:3px; border:1px solid var(--line); border-radius:10px; background:var(--surface-muted); } +.debug-source-tabs button,.debug-overlay-head .debug-clear { min-height:28px; padding:5px 8px; border-radius:7px; color:var(--muted); background:transparent; font-size:9px; font-weight:750; } +.debug-source-tabs button.active { color:var(--accent); background:var(--accent-soft); } +.debug-overlay-head .debug-clear { border:1px solid var(--line); background:var(--surface-muted); } +.debug-overlay-lines { max-height:min(38vh,360px); overflow:auto; padding:7px; font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace; } +.debug-line { display:grid; grid-template-columns:70px 48px 1fr; gap:8px; margin:3px 0; padding:7px 8px; border:1px solid var(--line); border-radius:9px; background:color-mix(in srgb,var(--surface-muted) 76%,transparent); font-size:10px; line-height:1.35; } +.debug-line.source-gree { border-color:color-mix(in srgb,var(--accent) 25%,var(--line)); } +.debug-line.source-request { border-color:color-mix(in srgb,var(--info) 24%,var(--line)); } +.debug-line time,.debug-line b { color:var(--muted); font-weight:650; } +.debug-line.source-gree b { color:var(--accent); } +.debug-line.source-request b { color:var(--info); } +.debug-line span { overflow-wrap:anywhere; white-space:pre-wrap; } .debug-empty { padding: 18px; color: var(--muted); text-align: center; font-size: 11px; } @media (max-width: 620px) { .custom-chart-add, .custom-chart-save { align-items: stretch; flex-direction: column; } .toast-stack { right: 12px; bottom: 88px; width: calc(100vw - 24px); } .debug-overlay { right: 12px; bottom: 88px; width: calc(100vw - 24px); } - .debug-line { grid-template-columns: 58px 38px 1fr; } + .debug-overlay-head { align-items:stretch; flex-direction:column; } + .debug-overlay-actions { justify-content:space-between; } + .debug-source-tabs { flex:1 1 auto; } + .debug-source-tabs button { flex:1 1 auto; } + .debug-line { grid-template-columns: 58px 42px 1fr; } + .system-panel-head { align-items:stretch; flex-direction:column; } + .system-health { align-self:flex-start; } + .simulation-mode-banner { align-items:stretch; flex-direction:column; gap:3px; } + .simulation-mode-banner strong { white-space:normal; } .influx-fields { grid-template-columns: 1fr; } .influx-fields label:last-child:nth-child(odd) { grid-column: auto; } } @@ -549,6 +578,11 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si .history-refresh-button { min-height:45px; align-self:end; } .settings-form { display:grid; gap:14px; } +.settings-mode-tabs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; margin:0 0 14px; padding:6px; border:1px solid var(--line); border-radius:17px; background:var(--surface); } +.settings-mode-tabs button { min-height:42px; border:1px solid transparent; border-radius:12px; color:var(--muted); background:transparent; font-size:12px; font-weight:800; } +.settings-mode-tabs button.active { border-color:var(--accent-border); color:var(--accent); background:var(--accent-soft); } +.settings-pane { display:grid; gap:14px; } +.settings-pane[hidden] { display:none; } .settings-block { display:grid; gap:18px; } .settings-block-head { display:flex; align-items:flex-start; gap:13px; } .settings-block-head h3 { margin:1px 0 4px; font-size:18px; } @@ -971,3 +1005,300 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36 } /* 0.7.1+: quick thermostats and quick manual control use the shared quick-control classes above. */ + +/* 0.8.11 mobile toolbar and chart inspection UX */ +.topbar { grid-template-columns: minmax(0, 1fr) auto auto; column-gap: 10px; } +.top-actions { min-width: 0; } +.language-picker { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + width: 58px; + height: 40px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-muted); + color: var(--text-soft); + transition: border-color .15s ease, background .15s ease, transform .15s ease; +} +.language-picker:hover { background: var(--surface-2); } +.language-picker:active { transform: scale(.97); } +.language-label { font-size: 11px; font-weight: 850; letter-spacing: .06em; line-height: 1; } +.language-chevron { margin-top: -2px; color: var(--muted); font-size: 13px; line-height: 1; } +.language-select { + position: absolute; + inset: 0; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + cursor: pointer; +} +.toolbar-icon-control { + display: inline-grid; + place-items: center; + flex: 0 0 40px; + width: 40px; + height: 40px; + min-height: 40px; + padding: 0; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-muted); + color: var(--text-soft); + font-size: 18px; + line-height: 1; +} +.toolbar-icon-control:hover { background: var(--surface-2); } +#refreshButton.toolbar-icon-control { font-size: 21px; } +button:focus-visible, +.language-picker:focus-within { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} + +.chart-hover-line { + position: absolute; + z-index: 3; + width: 1px; + background: color-mix(in srgb, var(--text) 40%, transparent); + pointer-events: none; +} +.chart-tooltip { + position: absolute; + z-index: 5; + min-width: 175px; + max-width: min(290px, calc(100vw - 32px)); + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: 12px; + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: 0 10px 28px rgba(0, 0, 0, .2); + color: var(--text); + font-size: 11px; + line-height: 1.35; + pointer-events: none; + backdrop-filter: blur(12px); +} +.chart-tooltip-time { display: block; margin-bottom: 7px; color: var(--text-soft); font-weight: 800; font-variant-numeric: tabular-nums; } +.chart-tooltip-values { display: grid; gap: 5px; } +.chart-tooltip-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; } +.chart-tooltip-label { display: inline-flex; min-width: 0; align-items: center; gap: 6px; color: var(--muted); } +.chart-tooltip-label i { flex: 0 0 7px; width: 7px; height: 7px; border-radius: 50%; background: var(--tooltip-color); } +.chart-tooltip-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chart-tooltip-value { color: var(--text); font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; } +.chart-tooltip-sample-time { color: var(--muted); font-size: 9px; font-weight: 600; } + +@media (max-width: 700px) { + .topbar { min-height: 64px; gap: 6px; padding: 9px 10px; } + .brand strong { font-size: 13px; } + .toolbar-runtime-status { padding: 6px 8px; font-size: 10px; } + .top-actions { gap: 5px; } + .language-picker { width: 50px; height: 38px; border-radius: 10px; } + .toolbar-icon-control { flex-basis: 38px; width: 38px; height: 38px; min-height: 38px; border-radius: 10px; } + .language-chevron { font-size: 11px; } +} + +@media (max-width: 360px) { + .topbar { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; } + .brand { grid-column: 1; grid-row: 1; } + .top-actions { grid-column: 2; grid-row: 1; } + .toolbar-runtime-status { + grid-column: 1 / -1; + grid-row: 2; + justify-self: stretch; + text-align: center; + } +} +.chart-tooltip-sample-time { display:block; margin-top:1px; } +@media (max-width: 700px) { .top-actions { width: auto; } } +.chart-hover-hint { display:block; margin-top:5px; color:var(--muted); font-size:10px; } +.chart-tooltip-value { text-align:right; } +.chart-wrap canvas:focus-visible { outline:2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset:-2px; } +.toolbar-runtime-status { min-width:max-content; } + +/* 0.8.13 settings separation, simulation warning, system status and debug filters */ +.toolbar-picker { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + flex: 0 0 54px; + width: 54px; + height: 40px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-muted); + color: var(--text-soft); + transition: border-color .15s ease, background .15s ease, transform .15s ease; +} +.toolbar-picker:hover { background: var(--surface-2); } +.toolbar-picker:active { transform: scale(.97); } +.toolbar-picker:focus-within { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} +.toolbar-picker-value { + min-width: 18px; + color: var(--text-soft); + font-size: 11px; + font-weight: 850; + letter-spacing: .06em; + line-height: 1; + text-align: center; +} +.theme-picker-value { font-size: 16px; letter-spacing: 0; } +.toolbar-picker-chevron { margin-top: -2px; color: var(--muted); font-size: 12px; line-height: 1; } +.toolbar-picker-select { + position: absolute; + inset: 0; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + cursor: pointer; +} + +.connection-banner { + grid-column: 1 / -1; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 11px; + border: 1px solid color-mix(in srgb, var(--danger) 34%, var(--line)); + border-radius: 13px; + background: color-mix(in srgb, var(--danger-soft) 78%, var(--surface)); +} +.connection-banner[hidden] { display: none; } +.simulation-mode-banner { grid-column:1 / -1; display:flex; align-items:center; justify-content:center; gap:14px; width:100%; padding:13px 16px; border:2px solid color-mix(in srgb,var(--warning) 62%,var(--line)); border-radius:14px; background:color-mix(in srgb,var(--warning-soft) 90%,var(--surface)); text-align:center; box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warning) 10%,transparent),0 5px 18px color-mix(in srgb,var(--warning) 10%,transparent); } +.simulation-mode-banner[hidden] { display:none; } +.simulation-mode-banner strong { color:var(--warning); font-size:clamp(18px,2.6vw,26px); font-weight:950; letter-spacing:.09em; line-height:1; white-space:nowrap; } +.simulation-mode-banner span { max-width:720px; color:var(--text-soft); font-size:12px; font-weight:700; line-height:1.35; } +.connection-banner-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 13%, transparent); } +.connection-banner > div { min-width: 0; } +.connection-banner strong, .connection-banner span { display: block; } +.connection-banner strong { margin-bottom: 1px; font-size: 11px; } +.connection-banner > div span { overflow: hidden; color: var(--muted); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.connection-banner button { min-height: 32px; padding: 6px 10px; border-radius: 9px; font-size: 10px; } +body.connection-lost .connection-dot { box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 13%, transparent); } + +.field-error { + display: block; + margin-top: -2px; + color: var(--danger); + font-size: 10px; + font-weight: 650; + line-height: 1.35; +} +.has-error > input, +.has-error > select, +.has-error > textarea, +input[aria-invalid="true"], +select[aria-invalid="true"], +textarea[aria-invalid="true"] { + border-color: color-mix(in srgb, var(--danger) 68%, var(--line)); + outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent); +} +.form-error-summary { + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line)); + border-radius: 12px; + background: var(--danger-soft); + color: var(--danger); + font-size: 11px; + font-weight: 650; + line-height: 1.4; +} +.settings-form > .form-error-summary { margin: 0 0 12px; } +.dialog-form > .form-error-summary { margin-top: -2px; } +.settings-form.is-saving, +.dialog-form.is-saving { cursor: progress; } +.button-spinner { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 7px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + vertical-align: -2px; + animation: button-spin .7s linear infinite; +} +@keyframes button-spin { to { transform: rotate(360deg); } } + +.chart-title-row { align-items: flex-start; } +.chart-zoom-controls { + display: inline-grid; + grid-template-columns: 32px minmax(52px, auto) 32px; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); +} +.chart-zoom-controls button { + min-width: 0; + height: 30px; + padding: 0 7px; + border-radius: 0; + border-right: 1px solid var(--line); + background: transparent; + color: var(--text-soft); + font-size: 12px; +} +.chart-zoom-controls button:last-child { border-right: 0; } +.chart-zoom-controls button:hover:not(:disabled) { background: var(--surface-2); } +.chart-zoom-controls button:disabled { cursor: default; opacity: .34; } +.chart-zoom-reset { color: var(--muted) !important; font-size: 10px !important; font-variant-numeric: tabular-nums; } +.chart-wrap { overscroll-behavior-inline: contain; scroll-behavior: smooth; } +.chart-wrap canvas { touch-action: pan-x pan-y; } + +@media (max-width: 700px) { + .toolbar-picker { flex-basis: 38px; width: 38px; height: 38px; border-radius: 10px; gap: 2px; } + .toolbar-picker-value { min-width: 15px; font-size: 10px; } + .theme-picker-value { font-size: 14px; } + .toolbar-picker-chevron { font-size: 9px; } + .connection-banner { grid-template-columns: auto minmax(0, 1fr) auto; padding: 8px 9px; } + .connection-banner > div span { white-space: normal; } + .chart-title-row { align-items: stretch; flex-direction: column; } + .chart-zoom-controls { align-self: flex-end; } +} + +@media (max-width: 360px) { + .connection-banner { grid-column: 1 / -1; } +} + +@media (max-width: 460px) { + .topbar { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + } + .brand { grid-column: 1; grid-row: 1; } + .top-actions { grid-column: 2; grid-row: 1; } + .toolbar-runtime-status { + grid-column: 1 / -1; + grid-row: 2; + justify-self: stretch; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + } + .connection-banner { grid-column: 1 / -1; grid-row: auto; } +} +.settings-form.has-unsaved-changes .settings-save-bar { + border-color: color-mix(in srgb, var(--accent) 48%, var(--line-strong)); + box-shadow: 0 8px 30px rgba(0,0,0,.18), 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); +} diff --git a/web/sw.js b/web/sw.js index e1c85b4..ad01b6d 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,7 +1,7 @@ -const CACHE = 'gree-controller-v0810-dashboard-alert-filters'; +const CACHE = 'gree-controller-v0813-settings-debug-docs'; const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/'; -const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')]; +const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json'), path('/lang/pl.json')]; self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting()))); self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()))); self.addEventListener('fetch', event => {