first commit

This commit is contained in:
Mateusz Gruszczyński
2026-08-23 21:34:07 +02:00
commit 1d3dcba1a9
62 changed files with 12456 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Serwer HTTP
GREE_BIND=0.0.0.0:8787
GREE_DATABASE=./data/gree-controller.db
RUST_LOG=gree_controller=info,tower_http=info
# Pusty token = dostęp bez logowania w zaufanej sieci LAN.
# Ustaw długi losowy token, gdy interfejs jest dostępny poza zaufanym VLAN-em.
GREE_APP_TOKEN=
# Tryb symulacji pozwala uruchomić aplikację bez klimatyzatora.
GREE_SIMULATE=true
GREE_AUTO_SEED=true
GREE_POLL_INTERVAL_SECONDS=15
GREE_ZONE_INTERVAL_SECONDS=5
GREE_DISCOVERY_TIMEOUT_MS=3000
GREE_DISCOVERY_BROADCAST=255.255.255.255:7000
GREE_CONTROLLER_ID=gree-controller
# Opcjonalny sensor temperatury z Home Assistant.
HA_URL=
HA_TOKEN=
HA_ENTITY_ID=
+44
View File
@@ -0,0 +1,44 @@
# Rust build output
/target/
**/*.rs.bk
# Local configuration and secrets
.env
.env.*
!.env.example
# Runtime data
/data/*
!/data/.gitkeep
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# Logs and diagnostics
*.log
*.pid
*.pcap
*.pcapng
# Editor and IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS metadata
.DS_Store
Thumbs.db
# Local packages and generated binaries
/*.zip
/*.tar.gz
/gree-controller
# Test and coverage artifacts
/coverage/
*.profraw
*.profdata
+70
View File
@@ -0,0 +1,70 @@
# GREE Controller v0.3.3 - build and validation report
## Scope of this release
Version 0.3.3 prepares the project for repeatable Debian/Ubuntu LXC testing and centralizes SQLite statements.
Changes include:
- all operator-facing shell/Python utilities are under `scripts/`,
- root-level `dev.sh` and `install-lxc.sh` were removed,
- `scripts/dev.sh` contains the previous development workflow,
- `scripts/install.sh` performs first systemd/LXC installation,
- `scripts/update.sh` performs tested in-place updates with stopped SQLite backup, health check and automatic rollback,
- `scripts/service.sh` wraps common service operations,
- `scripts/install-lxc.sh` is a compatibility alias located inside `scripts/`,
- `scripts/common.sh` provides shared deployment helpers,
- `docs/LXC.md` documents the installed filesystem layout and update process,
- every application SQLite statement and schema definition is now in `src/queries.rs`,
- `src/db.rs` contains no embedded SQL statements,
- Cargo package version updated to 0.3.3.
## LXC persistent paths
```text
/opt/gree-controller/gree-controller
/etc/gree-controller.env
/var/lib/gree-controller/gree-controller.db
/var/backups/gree-controller/<timestamp>/
/etc/systemd/system/gree-controller.service
```
The updater preserves `/etc/gree-controller.env` and backs up the stopped SQLite database before launching the new binary.
## Validation performed in the packaging environment
| Check | Result |
|---|---|
| `bash -n scripts/*.sh` | PASS |
| Python syntax for migration/HA integration | PASS |
| JSON parsing for language packs, HA translations and manifests | PASS |
| JavaScript syntax (`node --check web/app.js`) | PASS |
| No root-level operator `.sh`/`.py` files | PASS |
| SQL keyword scan outside `src/queries.rs` | PASS - no SQL statements found |
| EN/PL localization files preserved | PASS |
| `gree_controller` namespace preserved | PASS |
`shellcheck` is not installed in the packaging environment, so a shellcheck pass could not be performed.
The packaging environment also does not contain a Rust/Cargo toolchain, therefore the final compiler/type-check must be executed inside the target LXC. The provided installer does this by default.
Recommended first-install validation:
```bash
sudo ./scripts/install.sh
./scripts/service.sh status
./scripts/service.sh health
```
Recommended source/development validation:
```bash
./scripts/dev.sh --check
```
Recommended update validation with a newer archive:
```bash
sudo ./scripts/update.sh
./scripts/service.sh health
```
Generated
+2398
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "gree-controller"
version = "0.3.3"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
license = "MIT"
[dependencies]
aes = "0.8"
aes-gcm = "0.10"
anyhow = "1"
axum = { version = "0.7", features = ["macros", "ws"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4", features = ["derive", "env"] }
dotenvy = "0.15"
futures-util = "0.3"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["compression-gzip", "cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[build-dependencies]
serde_json = "1"
[dev-dependencies]
tempfile = "3"
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
panic = "abort"
+57
View File
@@ -0,0 +1,57 @@
f6ccf0fb91df0db03b045feb0de014b78690da50e86b6ae8cf128139effa0b8e ./.env.example
2fe1cf4e544fead5ae58436145a5b45e7a5a105143dfd815d28e307c94d5d19b ./.gitignore
d9d4dc23f77f1fc8319367a1eb53ee740807fd7a4a291a1add0a48056d832cec ./BUILD_REPORT.md
d9bea6d5fb8031f6923b9e1f78abe4648b67a33b5da70b587e8bf413f2b48125 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
615318767594b75456566494ae924e93a5b5b95bc58af49d5d74e25cfad173fb ./README.md
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./data/.gitkeep
597d7116a44dea68f1af679b327006a2397cec68751546d4c05d62a41dd17343 ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
bbd650c208779c04c95f7ee1153e6b3556351ee9aba282fa2a2f6315e5acf362 ./docs/LXC.md
a728ddc324613671932bd83522155b09c7503d4fbcd0d04d8e217676ea9e10e4 ./docs/PROJECT_SPEC.md
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
e0cf725c9f84be51cdbd83a5ab12b2b8288f90b36623cb4671cefcbf04378504 ./home-assistant/custom_components/gree_controller/api.py
f71f3ce47bcf6981c4044afdd321d96aa464125f55fdc4cc231013b2bfff284b ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py
2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
9901371e91ef74a534b16f06c92baf66347e74a619ec5e470a82889671a05097 ./home-assistant/custom_components/gree_controller/manifest.json
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
7a5381985f51b0cf7389d9ec2debf27181797c9d8d305b40b0ab516e29c47ed5 ./lang/en.json
4856406283b0ba0fb953b73ab6ddd7ea725257bcdbf55c42413b4e4d381211af ./lang/pl.json
5328833e82fabe3acd9d6a68a61a9550ba09ad941a2639ff9b791737a7d01ac2 ./scripts/README.md
be8c4bf17723d5e2e23e774a679ca75b02ab49825a93201237bf58cdd4974136 ./scripts/common.sh
054e6862857fd1d02dabedd977d4175c2011f451aba51b5d4fc3a3363e66e80f ./scripts/dev.sh
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
34d6aba352f0fc01e2882e7855d22ad620311084d814d23c1571defe1e31335f ./scripts/install.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
69a1c2ad30685cac517eeb18a27d40368506dc5e54b57d943c324339303afc2b ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
7567099da203faaaabd2f940936d162f4d376f731b17dbd4975fb87a92e22bf8 ./src/api.rs
ef7e85336fa3a33977c731b4e2f273dc19b6155a0e0fa2ce30e4f0e6ba8c757a ./src/config.rs
54f3449ea140b0a0eefb8cff1d95090e62d5a955e50fcad47bc10b669ea3d68e ./src/db.rs
281ad87b779d71bcd1f0cec4610a268b36ffe7e6f9813163d0fb236a61106dda ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
f85cb4ba6435843431d93779aa0653fd24ec8987e06ef36efde096cbea321d7a ./src/home_assistant.rs
3b14be5162ecb2aab357815d41a05042dbeedbdb7fc69a6a8210e7c50047a97d ./src/main.rs
48b2460a34af3b61830223431f6f400fa1339a8e9e929b639c437dcfa85767d3 ./src/models.rs
446615748ce2105457f0c81a295f6012e4dbc791894a64d45b4089a7dd8e4d7e ./src/protocol/crypto.rs
42b623e8616951cdab96e609b5c4d904f967932138762f05892f9258330a5332 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
c80164631bba476db29469fa43b28d73f68412c06bafabe2143ab8d4cd330a52 ./src/queries.rs
521070a4c63bec73372cf4873f20c5fad23c37db4df7199bcc5bf6f6e566b3f6 ./src/state.rs
52aea29e17e7ce45ea10f7783be7278ecefe976141c5823360f10f0ea2bbcf33 ./systemd/gree-controller.service
85781fef525c27356f8f70bc525553b96ee5adbcefdeadeece449dd3f16f21b0 ./web/app.js
b6bba1e1e7127d06c885a9f8fffbf0becde339e0f2464da8b4725f11ddabe433 ./web/favicon.svg
6439b633c76637c452d4d1e65f864ed87be122df94ced4891b9770eaa6cdef57 ./web/index.html
42143de65d81083938fc82aa601a38fb9f865860d628752580b0f1c8b77275d4 ./web/manifest.webmanifest
453d2041e6dc2ffa258dc7a6fcc27cb534047aa83722be0872d31b8f79b3ac49 ./web/styles.css
2ecdd077b73c4c725de8b642c6658992bcefe99b08a22a78eaf7903eaf9f813a ./web/sw.js
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 GREE Controller contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+299
View File
@@ -0,0 +1,299 @@
# 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_*`.
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.3.3**.
## Highlights
- local GREE discovery over UDP/7000,
- V1 bind/status/command transport using AES-128-ECB,
- V2 AES-128-GCM envelope support,
- power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light,
- SQLite state/history/event storage,
- temperature zones with hysteresis and minimum ON/OFF protection,
- weekly schedules including ranges that cross midnight,
- temperature/time automations,
- per-zone optional Home Assistant room-temperature sensors with GREE fallback,
- combined zone temperature using configurable GREE/external sensor weighting and discrepancy protection,
- REST API and WebSocket updates,
- responsive PWA optimized for phones,
- 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 `climate` commands through this controller,
- migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`.
See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow.
## Quick start
On Debian, Ubuntu or an LXC container:
```bash
unzip gree-controller-v0.3.3.zip
cd gree-controller
chmod +x scripts/*.sh
./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
```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
```
## Web interface
The UI is mobile-first and uses no external CDN.
Language selector:
- 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 `<code>.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 interface uses flat surfaces and borders; decorative UI shadows were removed in v0.2.0.
## 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. Use **Bind** or send a command so the controller can obtain/use the device key.
4. If LXC/VLAN broadcast does not pass, add the unit manually with IP and MAC/CID.
Some GREE firmware families use protocol variations. V1 covers common Wi-Fi units. V2 implements the standard AES-GCM envelope but unusual firmware may require protocol-specific adaptation.
## LXC/systemd installation and updates
All operator scripts are under `scripts/`. On a clean Debian/Ubuntu LXC container:
```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.
For a later release, unpack the new source archive and run:
```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/<timestamp>/`. After replacement it checks `/api/health`. A failed startup triggers automatic rollback to the previous binary, unit and database backup.
Service helpers:
```bash
./scripts/service.sh status
sudo ./scripts/service.sh restart
./scripts/service.sh logs
./scripts/service.sh health
```
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`.
## Environment 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` | `true` | Enables simulator support |
| `GREE_CONTROLLER_AUTO_SEED` | `true` | Seeds a simulator into an empty database |
| `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_ID` | `gree-controller` | GREE protocol client CID |
| `HA_URL` | empty | Optional Home Assistant URL |
| `HA_TOKEN` | empty | Optional Home Assistant Long-Lived Access Token |
| `HA_ENTITY_ID` | empty | Optional default HA temperature sensor |
Settings changed from the web panel are stored in SQLite. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup.
## Per-zone room temperature sensors
Each control zone can pair one GREE indoor unit with its own optional room sensor from Home Assistant. This is intentionally configured per zone, so rooms do not share a global temperature source.
Example:
```text
Living room -> GREE Living Room + sensor.living_room_temperature
Bedroom -> GREE Bedroom + sensor.bedroom_temperature
Office -> GREE Office + sensor.office_temperature
```
Zone temperature strategies:
- **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.
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 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.
## Home Assistant
There are two independent HA 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.
For the HA client connection, open **Settings -> Home Assistant integration access** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/command API; they cannot change controller settings or manage other tokens.
The second option is designed to replace the built-in/default GREE integration without changing automation/dashboard references.
Example migration target:
```text
old: climate.klima_salon -> built-in GREE integration
new: climate.klima_salon -> GREE Controller custom integration -> Rust API -> AC
```
Generate an entity mapping:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--device gree-aabbccddeeff
```
Or validate against the controller and automatically use its only device:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--controller-url http://192.168.1.20:8787 \
--controller-token YOUR_CONTROLLER_TOKEN
```
The generated file must be copied to:
```text
/config/gree_controller_entities.json
```
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).
## API
Important endpoints:
```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
GET /api/schedules
POST /api/schedules
GET /api/automations
POST /api/automations
GET /api/readings
GET /api/events
GET /api/settings
PUT /api/settings
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
WS /ws
```
Example command:
```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}'
```
`/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=...`.
More examples: [`docs/API.md`](docs/API.md).
## 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.
## Backup
For a systemd installation:
```bash
systemctl stop gree-controller
cp /var/lib/gree-controller/gree-controller.db /safe/backup/location/
systemctl start gree-controller
```
Development data is stored under `data/` by default.
## 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
```
## License
MIT. This project uses a community-reconstructed local device protocol and is not an official product of GREE Electric Appliances Inc.
+93
View File
@@ -0,0 +1,93 @@
use serde_json::{json, Value};
use std::{env, fs, path::PathBuf};
fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str {
meta.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string"))
}
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let lang_dir = manifest_dir.join("lang");
println!("cargo:rerun-if-changed={}", lang_dir.display());
let mut files: Vec<PathBuf> = fs::read_dir(&lang_dir)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_dir.display()))
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.collect();
files.sort();
if files.is_empty() {
panic!("no language files found in {}", lang_dir.display());
}
let mut assets = Vec::new();
let mut manifest_languages = Vec::new();
let mut has_english = false;
for path in files {
println!("cargo:rerun-if-changed={}", path.display());
let filename = path.file_name().and_then(|v| v.to_str()).expect("UTF-8 language filename");
let stem = path.file_stem().and_then(|v| v.to_str()).expect("UTF-8 language code");
if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'");
}
let source = fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
let document: Value = serde_json::from_str(&source)
.unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}"));
let meta = document.get("meta").unwrap_or_else(|| panic!("{filename}: missing meta object"));
let code = required_string(meta, "code", filename);
let name = required_string(meta, "name", filename);
let native_name = required_string(meta, "native_name", filename);
let locale = required_string(meta, "locale", filename);
if code != stem {
panic!("{filename}: meta.code '{code}' must match filename '{stem}.json'");
}
if !document.get("translations").is_some_and(Value::is_object) {
panic!("{filename}: translations must be a JSON object");
}
if code == "en" {
has_english = true;
}
manifest_languages.push(json!({
"code": code,
"name": name,
"native_name": native_name,
"locale": locale,
"path": format!("/lang/{code}.json")
}));
assets.push((code.to_owned(), path));
}
if !has_english {
panic!("lang/en.json is required as the default fallback language");
}
let manifest_json = serde_json::to_string(&json!({
"default": "en",
"languages": manifest_languages
})).expect("serialize language manifest");
let mut generated = String::new();
generated.push_str("// @generated by build.rs - do not edit.\n");
generated.push_str(&format!("pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", manifest_json));
generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n");
for (code, path) in assets {
generated.push_str(&format!(
" ({:?}, include_str!({:?})),\n",
code,
path.to_string_lossy()
));
}
generated.push_str("];\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
fs::write(out_dir.join("languages.rs"), generated).expect("write generated languages.rs");
}
View File
Executable
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
MODE="debug"
ACTION="run"
INSTALL_DEPS=1
RESET_DB=0
HOST=""
PORT=""
usage() {
cat <<'TXT'
GREE Controller - development environment
Usage:
./dev.sh install missing tools, build and run
./dev.sh --release run an optimized build
./dev.sh --check formatting, tests, build and API smoke test
./dev.sh --reset remove the local database before startup
./dev.sh --no-install do not install system packages or Rust
./dev.sh --host 0.0.0.0 --port 8787
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--release) MODE="release"; shift ;;
--check) ACTION="check"; shift ;;
--reset) RESET_DB=1; shift ;;
--no-install) INSTALL_DEPS=0; shift ;;
--host) HOST="${2:?missing value for --host}"; shift 2 ;;
--port) PORT="${2:?missing value for --port}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
run_privileged() {
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else fail "Root/sudo privileges are required to install dependencies: $*"
fi
}
install_build_tools() {
[[ "$INSTALL_DEPS" -eq 1 ]] || return 0
local missing=0
command -v curl >/dev/null 2>&1 || missing=1
command -v cc >/dev/null 2>&1 || missing=1
if [[ "$missing" -eq 0 ]]; then return 0; fi
if command -v apt-get >/dev/null 2>&1; then
say "Installing build packages (Debian/Ubuntu/LXC)"
run_privileged apt-get update
run_privileged apt-get install -y --no-install-recommends build-essential curl ca-certificates pkg-config
elif command -v dnf >/dev/null 2>&1; then
say "Installing build packages (Fedora/RHEL)"
run_privileged dnf install -y gcc gcc-c++ make curl ca-certificates pkgconf-pkg-config
elif command -v apk >/dev/null 2>&1; then
say "Installing build packages (Alpine)"
run_privileged apk add --no-cache build-base curl ca-certificates pkgconf
else
fail "Unsupported package manager. Install a C compiler, make, curl and CA certificates."
fi
}
install_rust() {
if command -v cargo >/dev/null 2>&1; then return 0; fi
[[ "$INSTALL_DEPS" -eq 1 ]] || fail "Cargo is not installed and --no-install was requested"
command -v curl >/dev/null 2>&1 || fail "curl is required to install Rust"
say "Installing stable Rust with rustup"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
# shellcheck disable=SC1090
source "$HOME/.cargo/env"
}
install_build_tools
install_rust
command -v cargo >/dev/null 2>&1 || fail "Cargo is still unavailable"
if [[ ! -f .env ]]; then
cp .env.example .env
say "Created .env from the example configuration"
fi
mkdir -p data
if [[ "$RESET_DB" -eq 1 ]]; then
rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal
say "Removed the local database"
fi
# Export simple KEY=VALUE entries from .env.
set -a
# shellcheck disable=SC1091
source ./.env
set +a
if [[ -n "$HOST" || -n "$PORT" ]]; then
current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
current_host="${current%:*}"
current_port="${current##*:}"
export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}"
fi
if [[ "$ACTION" == "check" ]]; then
say "Checking formatting"
cargo fmt --all -- --check
say "Running Rust tests"
cargo test --all-targets
say "Building the application"
cargo build
say "Running HTTP/API smoke test"
./scripts/smoke.sh
say "All checks passed"
exit 0
fi
if [[ "$MODE" == "release" ]]; then
say "Building release version"
cargo build --release
BINARY="$ROOT/target/release/gree-controller"
else
say "Building debug version"
cargo build
BINARY="$ROOT/target/debug/gree-controller"
fi
bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
display_host="${bind%:*}"
[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1"
say "Panel: http://${display_host}:${bind##*:}"
say "Stop: Ctrl+C"
exec "$BINARY"
+112
View File
@@ -0,0 +1,112 @@
# API examples
`TOKEN` is optional when `GREE_CONTROLLER_APP_TOKEN` is empty.
```bash
AUTH='Authorization: Bearer TOKEN'
BASE='http://127.0.0.1:8787'
```
## Home Assistant access tokens
Create and revoke integration tokens from the controller Web UI under **Settings -> Home Assistant integration access**. The clear-text secret is returned only once and the SQLite database stores only its SHA-256 hash.
Administrator endpoints:
```text
GET /api/access-tokens
POST /api/access-tokens
DELETE /api/access-tokens/{id}
```
The Home Assistant custom integration uses a restricted API surface:
```text
GET /api/integrations/home-assistant/devices
POST /api/integrations/home-assistant/devices/{id}/command
```
These two endpoints always require `Authorization: Bearer <generated-token>` (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.
## Discovery
```bash
curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"timeout_ms":3000,"broadcast":"255.255.255.255:7000"}'
```
## Device command
```json
{
"power": true,
"mode": "cool",
"target_temperature": 22.0,
"fan_speed": 3,
"swing_vertical": true,
"quiet": false,
"turbo": false,
"light": true
}
```
Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`.
## Zone
```json
{
"name": "Living room",
"device_id": "gree-aabbccddeeff",
"enabled": true,
"mode": "cool",
"setpoint": 23.0,
"hysteresis": 0.6,
"min_on_seconds": 180,
"min_off_seconds": 180,
"sensor_source": "combined",
"ha_entity_id": "sensor.living_room_temperature",
"external_sensor_weight": 0.4,
"max_sensor_difference": 3.0
}
```
`sensor_source` supports:
- `device` — GREE indoor sensor only,
- `combined` — GREE + this zone's HA room sensor,
- `home_assistant` — this zone's HA room sensor, with GREE fallback.
For `combined` and `home_assistant`, set a per-zone `ha_entity_id`. `external_sensor_weight` is `0.0..1.0`. If a combined sensor pair differs by more than `max_sensor_difference`, the controller falls back to GREE. The returned zone object includes `device_temperature`, `external_temperature`, `current_temperature`, and `control_temperature_source`.
## Schedule
Weekdays use ISO numbers: Monday `1`, Sunday `7`.
```json
{
"zone_id": "UUID",
"name": "Night",
"enabled": true,
"weekdays": [1,2,3,4,5,6,7],
"start_time": "22:00",
"end_time": "06:00",
"setpoint": 24.0
}
```
## WebSocket
Connect to `ws://HOST:8787/ws?token=TOKEN`. The first message uses event type `bootstrap`; later events include `device.updated`, `zone.updated`, `settings.updated` and `log.created`.
## Localization assets
Localization endpoints are public because the login dialog also needs translations. Language packs are embedded in the Rust binary at build time.
```bash
curl "$BASE/lang/index.json"
curl "$BASE/lang/en.json"
```
`GET /lang/index.json` returns the automatically generated language catalog. `GET /lang/<code>.json` returns the corresponding language pack. Add a valid `lang/<code>.json` file and rebuild to expose a new language.
+67
View File
@@ -0,0 +1,67 @@
# 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.
+71
View File
@@ -0,0 +1,71 @@
# 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/<code>.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.
+110
View File
@@ -0,0 +1,110 @@
# 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/<UTC timestamp>/`,
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.
+162
View File
@@ -0,0 +1,162 @@
# 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.
## Temperature zones
A zone connects an AC to a temperature-control policy. It contains:
- target setpoint,
- heat/cool mode,
- hysteresis,
- minimum ON time,
- minimum OFF time,
- temperature source,
- optional Home Assistant sensor entity.
Each zone can assign its own Home Assistant room sensor. Combined mode fuses the GREE and room measurements with a configurable weight (40% room sensor by default), while GREE remains the primary input. If HA is unavailable or the two sensors differ beyond the configured limit, control falls back to the GREE sensor.
## Scheduler and automations
Schedules are weekly time windows and may cross midnight. Automations currently support time or temperature triggers and send device commands with cooldown protection.
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 `<code>.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.
## 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.
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
# Home Assistant integration
The project includes a custom Home Assistant integration under:
```text
home-assistant/custom_components/gree_controller/
```
It creates HA `climate` entities but sends every command to the standalone Rust controller. Home Assistant therefore becomes a client, while UDP/AES GREE communication remains outside HA.
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
## Install the custom integration
Copy the directory into your HA configuration:
```text
/config/custom_components/gree_controller/
```
Before adding the integration, open the standalone controller Web UI and go to **Settings -> Home Assistant integration access -> Create new token**. Copy the generated secret; it is shown only once.
Restart Home Assistant, then open **Settings -> Devices & services -> Add integration -> GREE Controller** and enter only:
- controller URL, for example `http://192.168.1.20:8787`,
- the generated controller access token.
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices and sending climate commands.
## Preserve an existing entity ID
If existing automations and dashboards use an entity such as:
```text
climate.klima_salon
```
use the migration generator before switching integrations:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--device gree-aabbccddeeff
```
Copy the generated JSON file to:
```text
/config/gree_controller_entities.json
```
The custom integration reads this file and requests the exact same `climate.*` entity ID.
Home Assistant cannot have two active entities with the same `entity_id`. Therefore the old/default GREE entity must release `climate.klima_salon` before the new integration is loaded. Do not run both integrations against the same entity ID.
Safe order:
1. Configure and test the standalone Rust controller first.
2. Confirm the AC can be controlled from the GREE Controller web UI.
3. Generate and copy `gree_controller_entities.json`.
4. Disable or remove the old/default GREE integration entry in Home Assistant.
5. If its old entity registry record remains, remove that stale entity from HA after the old integration is unloaded.
6. Install/restart the `gree_controller` custom integration.
7. Verify that `climate.klima_salon` exists and controls the AC through the Rust service.
8. Check existing dashboards, scripts and automations. Because the entity ID is unchanged, references to `climate.klima_salon` do not need to be rewritten.
The integration deliberately fails setup on an entity-ID conflict instead of silently creating `climate.klima_salon_2`.
## Multiple devices
Use repeated mappings:
```bash
./scripts/generate_ha_migration.py \
--map climate.klima_salon=gree-aabbccddeeff \
--map climate.klima_sypialnia=gree-112233445566
```
## Optional validation
Validate the target controller device:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--controller-url http://192.168.1.20:8787 \
--controller-token CONTROLLER_TOKEN
```
Validate that the source HA entity currently exists as well:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--device gree-aabbccddeeff \
--ha-url http://homeassistant.local:8123 \
--ha-token HOME_ASSISTANT_LONG_LIVED_TOKEN
```
Tokens are used only during validation and are not written to the mapping file.
## HA as an external temperature source
This is independent from the custom climate integration. Each Rust controller zone can assign its own HA room-temperature entity, for example:
```text
Living room -> GREE Living Room + sensor.living_room_temperature
Bedroom -> GREE Bedroom + sensor.bedroom_temperature
```
The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the room sensor as a configurable supporting measurement (40% weight by default). A zone may also select the room sensor as its preferred source. If HA or that entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run.
@@ -0,0 +1,51 @@
"""Home Assistant bridge for the standalone GREE Controller service."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import GreeControllerApiError, GreeControllerClient
from .const import CONF_TOKEN, CONF_URL, PLATFORMS
from .coordinator import GreeControllerCoordinator
from .entity_map import async_load_entity_map
@dataclass
class GreeControllerRuntimeData:
"""Runtime objects kept on the config entry."""
client: GreeControllerClient
coordinator: GreeControllerCoordinator
entity_map: dict[str, str]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up GREE Controller from a config entry."""
client = GreeControllerClient(
async_get_clientsession(hass),
entry.data[CONF_URL],
entry.data.get(CONF_TOKEN, ""),
)
try:
await client.devices()
except GreeControllerApiError as err:
if "authentication" in str(err).lower():
raise ConfigEntryAuthFailed(str(err)) from err
raise ConfigEntryNotReady(str(err)) from err
coordinator = GreeControllerCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entity_map = await async_load_entity_map(hass)
entry.runtime_data = GreeControllerRuntimeData(client, coordinator, entity_map)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload the config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,76 @@
"""HTTP client for the standalone GREE Controller service."""
from __future__ import annotations
from typing import Any
from aiohttp import ClientError, ClientSession
class GreeControllerApiError(Exception):
"""Raised when the controller API cannot be used."""
class GreeControllerClient:
"""Small async client backed by Home Assistant's shared ClientSession."""
def __init__(self, session: ClientSession, base_url: str, token: str = "") -> None:
self._session = session
self._base_url = base_url.rstrip("/")
self._token = token.strip()
@property
def base_url(self) -> str:
"""Return the normalized controller URL."""
return self._base_url
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
try:
async with self._session.request(
method,
f"{self._base_url}{path}",
headers=self._headers(),
timeout=10,
**kwargs,
) as response:
if response.status == 401:
raise GreeControllerApiError("Controller authentication failed")
if response.status >= 400:
try:
body = await response.json()
message = body.get("error", f"HTTP {response.status}")
except (ValueError, TypeError):
message = f"HTTP {response.status}"
raise GreeControllerApiError(message)
if response.status == 204:
return None
return await response.json()
except GreeControllerApiError:
raise
except (ClientError, TimeoutError) as err:
raise GreeControllerApiError(str(err)) from err
async def health(self) -> dict[str, Any]:
"""Return the public controller health payload."""
return await self._request("GET", "/api/health")
async def devices(self) -> list[dict[str, Any]]:
"""Return all controller devices."""
data = await self._request("GET", "/api/integrations/home-assistant/devices")
if not isinstance(data, list):
raise GreeControllerApiError("Controller returned an invalid devices payload")
return data
async def command(self, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Send a device command and return the updated device state."""
return await self._request(
"POST",
f"/api/integrations/home-assistant/devices/{device_id}/command",
json=payload,
)
@@ -0,0 +1,204 @@
"""Climate entities proxied through the standalone GREE Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
SWING_OFF,
SWING_ON,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
_LOGGER = logging.getLogger(__name__)
MODE_TO_HA = {
"auto": HVACMode.AUTO,
"cool": HVACMode.COOL,
"dry": HVACMode.DRY,
"fan": HVACMode.FAN_ONLY,
"heat": HVACMode.HEAT,
}
HA_TO_MODE = {value: key for key, value in MODE_TO_HA.items()}
FAN_TO_NAME = {0: "auto", 1: "low", 2: "medium_low", 3: "medium", 4: "medium_high", 5: "high"}
NAME_TO_FAN = {value: key for key, value in FAN_TO_NAME.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create climate entities for all devices exposed by the controller."""
runtime: GreeControllerRuntimeData = entry.runtime_data
registry = er.async_get(hass)
entities: list[GreeControllerClimate] = []
for device_id, device in runtime.coordinator.data.items():
desired_entity_id = runtime.entity_map.get(device_id)
unique_id = f"{device_id}-climate"
if desired_entity_id:
if not desired_entity_id.startswith("climate."):
raise ConfigEntryError(f"Mapped entity ID must use the climate domain: {desired_entity_id}")
existing = registry.async_get(desired_entity_id)
if existing and not (existing.platform == DOMAIN and existing.unique_id == unique_id):
raise ConfigEntryError(
f"Entity ID {desired_entity_id} is still reserved by integration {existing.platform}. "
"Disable/remove the previous GREE integration and remove its entity registry entry before takeover."
)
if hass.states.get(desired_entity_id) is not None and existing is None:
raise ConfigEntryError(
f"Entity ID {desired_entity_id} is still active in Home Assistant. "
"Unload the previous integration before takeover."
)
entities.append(
GreeControllerClimate(runtime.coordinator, device_id, desired_entity_id)
)
async_add_entities(entities)
class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
"""Home Assistant climate entity controlled through the Rust service."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = 8.0
_attr_max_temp = 32.0
_attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY, HVACMode.FAN_ONLY]
_attr_fan_modes = list(NAME_TO_FAN)
_attr_swing_modes = [SWING_OFF, SWING_ON]
_attr_swing_horizontal_modes = [SWING_OFF, SWING_ON]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.SWING_HORIZONTAL_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: GreeControllerCoordinator,
device_id: str,
requested_entity_id: str | None,
) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_unique_id = f"{device_id}-climate"
self._attr_name = None
if requested_entity_id:
# This is intentionally limited to same-domain takeover migrations.
# The setup guard above prevents accidental collisions.
self.entity_id = requested_entity_id
@property
def _device(self) -> dict[str, Any]:
return self.coordinator.data.get(self._device_id, {})
@property
def available(self) -> bool:
return super().available and bool(self._device.get("online", False))
@property
def device_info(self) -> DeviceInfo:
device = self._device
return DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
name=str(device.get("name") or self._device_id),
manufacturer="GREE",
model=str(device.get("model") or "GREE HVAC"),
sw_version=str(device.get("firmware") or "") or None,
)
@property
def current_temperature(self) -> float | None:
value = self._device.get("current_temperature")
return float(value) if value is not None else None
@property
def target_temperature(self) -> float | None:
value = self._device.get("target_temperature")
return float(value) if value is not None else None
@property
def hvac_mode(self) -> HVACMode:
device = self._device
if not device.get("power", False):
return HVACMode.OFF
return MODE_TO_HA.get(str(device.get("mode", "auto")), HVACMode.AUTO)
@property
def fan_mode(self) -> str:
return FAN_TO_NAME.get(int(self._device.get("fan_speed", 0)), "auto")
@property
def swing_mode(self) -> str:
return SWING_ON if self._device.get("swing_vertical", False) else SWING_OFF
@property
def swing_horizontal_mode(self) -> str:
return SWING_ON if self._device.get("swing_horizontal", False) else SWING_OFF
@property
def extra_state_attributes(self) -> dict[str, Any]:
device = self._device
return {
"controller_device_id": self._device_id,
"controller_online": bool(device.get("online", False)),
"quiet": bool(device.get("quiet", False)),
"turbo": bool(device.get("turbo", False)),
"light": bool(device.get("light", False)),
"last_seen": device.get("last_seen"),
}
async def _command(self, payload: dict[str, Any]) -> None:
await self.coordinator.client.command(self._device_id, payload)
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
await self._command({"power": True})
async def async_turn_off(self) -> None:
await self._command({"power": False})
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self._command({"power": False})
return
mode = HA_TO_MODE.get(hvac_mode)
if mode is None:
return
await self._command({"power": True, "mode": mode})
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is not None:
await self._command({"target_temperature": float(temperature)})
async def async_set_fan_mode(self, fan_mode: str) -> None:
if fan_mode in NAME_TO_FAN:
await self._command({"fan_speed": NAME_TO_FAN[fan_mode]})
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self._command({"swing_vertical": swing_mode == SWING_ON})
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
await self._command({"swing_horizontal": swing_horizontal_mode == SWING_ON})
@@ -0,0 +1,46 @@
"""UI configuration flow for GREE Controller."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import GreeControllerApiError, GreeControllerClient
from .const import CONF_TOKEN, CONF_URL, DOMAIN
class GreeControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Configure a standalone GREE Controller instance."""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None):
"""Handle the initial connection form."""
errors: dict[str, str] = {}
if user_input is not None:
url = str(user_input[CONF_URL]).strip().rstrip("/")
token = str(user_input[CONF_TOKEN]).strip()
client = GreeControllerClient(async_get_clientsession(self.hass), url, token)
try:
await client.devices()
except GreeControllerApiError as err:
errors["base"] = "invalid_auth" if "authentication" in str(err).lower() else "cannot_connect"
else:
await self.async_set_unique_id("gree-controller")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="GREE Controller",
data={CONF_URL: url, CONF_TOKEN: token},
)
schema = vol.Schema(
{
vol.Required(CONF_URL, default="http://gree-controller:8787"): str,
vol.Required(CONF_TOKEN): str,
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@@ -0,0 +1,11 @@
"""Constants for the GREE Controller Home Assistant integration."""
from homeassistant.const import Platform
DOMAIN = "gree_controller"
PLATFORMS = [Platform.CLIMATE]
CONF_URL = "url"
CONF_TOKEN = "token"
ENTITY_MAP_FILE = "gree_controller_entities.json"
DEFAULT_SCAN_INTERVAL_SECONDS = 10
@@ -0,0 +1,36 @@
"""Data coordinator for the GREE Controller integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import GreeControllerApiError, GreeControllerClient
from .const import DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN
_LOGGER = logging.getLogger(__name__)
class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Poll the standalone controller and cache device states."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: GreeControllerClient) -> None:
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS),
)
self.client = client
async def _async_update_data(self) -> dict[str, dict]:
try:
devices = await self.client.devices()
except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err
return {str(device["id"]): device for device in devices if device.get("id")}
@@ -0,0 +1,34 @@
"""Load optional legacy entity ID mappings for GREE Controller."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from homeassistant.core import HomeAssistant
from .const import ENTITY_MAP_FILE
def _load(path: Path) -> dict[str, str]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as handle:
data: Any = json.load(handle)
items = data.get("entities", []) if isinstance(data, dict) else []
result: dict[str, str] = {}
for item in items:
if not isinstance(item, dict):
continue
device_id = str(item.get("device_id", "")).strip()
entity_id = str(item.get("entity_id", "")).strip()
if device_id and entity_id.startswith("climate."):
result[device_id] = entity_id
return result
async def async_load_entity_map(hass: HomeAssistant) -> dict[str, str]:
"""Load the optional mapping file without blocking Home Assistant's loop."""
path = Path(hass.config.path(ENTITY_MAP_FILE))
return await hass.async_add_executor_job(_load, path)
@@ -0,0 +1,9 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.3.3",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
"single_config_entry": true
}
@@ -0,0 +1,22 @@
{
"title": "GREE Controller",
"config": {
"step": {
"user": {
"title": "Connect to GREE Controller",
"description": "Connect Home Assistant to the standalone Rust controller. Device commands will be proxied through the controller instead of the built-in GREE integration.",
"data": {
"url": "Controller URL",
"token": "API token"
}
}
},
"error": {
"cannot_connect": "Cannot connect to GREE Controller",
"invalid_auth": "Invalid controller API token"
},
"abort": {
"already_configured": "GREE Controller is already configured"
}
}
}
@@ -0,0 +1,22 @@
{
"title": "GREE Controller",
"config": {
"step": {
"user": {
"title": "Połącz z GREE Controller",
"description": "Połącz Home Assistant z niezależnym kontrolerem Rust. Polecenia urządzeń będą przechodziły przez kontroler zamiast wbudowanej integracji GREE.",
"data": {
"url": "Adres URL kontrolera",
"token": "Token API"
}
}
},
"error": {
"cannot_connect": "Nie można połączyć się z GREE Controller",
"invalid_auth": "Nieprawidłowy token API kontrolera"
},
"abort": {
"already_configured": "GREE Controller jest już skonfigurowany"
}
}
}
@@ -0,0 +1,9 @@
{
"version": 1,
"entities": [
{
"entity_id": "climate.klima_salon",
"device_id": "gree-aabbccddeeff"
}
]
}
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -Eeuo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Run as root: sudo ./install-lxc.sh" >&2; exit 1; }
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
apt-get update
apt-get install -y --no-install-recommends build-essential curl ca-certificates pkg-config
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
source /root/.cargo/env
fi
cargo build --release
id gree-controller >/dev/null 2>&1 || useradd --system --home /var/lib/gree-controller --shell /usr/sbin/nologin gree-controller
install -d -o gree-controller -g gree-controller -m 0750 /var/lib/gree-controller
install -d -o root -g root -m 0755 /opt/gree-controller
install -o root -g root -m 0755 target/release/gree-controller /opt/gree-controller/gree-controller
install -o root -g root -m 0644 systemd/gree-controller.service /etc/systemd/system/gree-controller.service
if [[ ! -f /etc/gree-controller.env ]]; then
token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
cat > /etc/gree-controller.env <<ENV
GREE_CONTROLLER_BIND=0.0.0.0:8787
GREE_CONTROLLER_DATABASE=/var/lib/gree-controller/gree-controller.db
GREE_CONTROLLER_APP_TOKEN=$token
GREE_CONTROLLER_SIMULATE=true
GREE_CONTROLLER_AUTO_SEED=true
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
GREE_CONTROLLER_ID=gree-controller
RUST_LOG=info,tower_http=info
HA_URL=
HA_TOKEN=
HA_ENTITY_ID=
ENV
chmod 0600 /etc/gree-controller.env
echo "Generated API token: $token"
echo "Saved to /etc/gree-controller.env"
fi
systemctl daemon-reload
systemctl enable --now gree-controller.service
systemctl --no-pager --full status gree-controller.service || true
ip="$(hostname -I | awk '{print $1}')"
echo "Panel: http://${ip:-LXC_ADDRESS}:8787"
echo "Logs: journalctl -u gree-controller -f"
+9
View File
@@ -0,0 +1,9 @@
# Language packs
Each `*.json` file in this directory becomes an available UI language after the Rust project is rebuilt.
- `en.json` is required and is the fallback language.
- `pl.json` provides Polish.
- To add a language, copy `en.json`, rename it to `<code>.json`, update `meta`, translate values, and rebuild.
See `docs/LOCALIZATION.md` for the full format and validation rules.
+231
View File
@@ -0,0 +1,231 @@
{
"meta": {
"code": "en",
"name": "English",
"native_name": "English",
"locale": "en-GB"
},
"translations": {
"meta.description": "Local GREE air conditioner controller",
"status.connecting": "Connecting…",
"status.connected": "Connected",
"status.disconnected": "Disconnected",
"status.connectionError": "Connection error",
"status.online": "Online",
"status.offline": "Offline",
"controls.language": "Language",
"controls.theme": "Theme",
"theme.system": "System",
"theme.light": "Light",
"theme.dark": "Dark",
"nav.navigation": "Navigation",
"nav.dashboard": "Dashboard",
"nav.devices": "Devices",
"nav.zones": "Zones",
"nav.history": "History",
"nav.more": "More",
"nav.schedules": "Schedules",
"nav.automations": "Automations",
"nav.settings": "Settings",
"nav.logs": "Events",
"nav.logsAndEvents": "Events and logs",
"actions.refresh": "Refresh",
"actions.discover": "Discover",
"actions.discovering": "Searching…",
"actions.add": "Add",
"actions.show": "Show",
"actions.save": "Save",
"actions.cancel": "Cancel",
"actions.edit": "Edit",
"actions.delete": "Delete",
"actions.read": "Read",
"actions.bind": "Bind",
"actions.noChange": "No change",
"actions.turnOn": "Turn on",
"actions.turnOff": "Turn off",
"dashboard.home": "Home",
"dashboard.title": "Comfort under control",
"dashboard.loading": "Loading devices…",
"dashboard.quickControl": "Quick control",
"dashboard.summary": "{online} of {total} devices online{active}.",
"dashboard.summaryActive": ", {count} running",
"dashboard.empty": "No devices. Start discovery or add a device manually.",
"dashboard.metricOnline": "Online",
"dashboard.metricActive": "Active",
"dashboard.metricDemand": "Zones requesting",
"devices.lan": "LAN network",
"devices.addManual": "Add manually",
"devices.add": "Add device",
"devices.ipAddress": "IP address",
"devices.protocol": "Protocol",
"devices.keyOptional": "Key (optional)",
"devices.keyPlaceholder": "Leave empty for automatic bind",
"devices.simulated": "Simulated device",
"devices.simulator": "simulator",
"devices.power": "Power",
"devices.currentTemperature": "Temperature",
"devices.outdoor": "outside",
"devices.quiet": "Quiet",
"devices.swing": "Swing",
"devices.turbo": "Turbo",
"devices.emptyTitle": "No devices",
"devices.emptyText": "Use Discover while the controller is on the same LAN.",
"devices.readDone": "Device state updated",
"devices.bound": "Device bound",
"devices.added": "Device added",
"zones.automation": "Automation",
"zones.new": "New zone",
"zones.description": "A zone controls a device using temperature, hysteresis and minimum cycle time.",
"zones.hysteresis": "Hysteresis °C",
"zones.source": "Temperature strategy",
"zones.greeSensor": "GREE only",
"zones.combinedSensor": "GREE + room sensor",
"zones.externalSensor": "Room sensor only",
"zones.minOn": "Min. ON (s)",
"zones.minOff": "Min. OFF (s)",
"zones.greeSource": "GREE sensor",
"zones.combinedSource": "GREE + room sensor",
"zones.externalSource": "room sensor",
"zones.measurement": "Control temperature",
"zones.demand": "Demand",
"zones.emptyTitle": "No zones",
"zones.emptyText": "Add a zone to control temperature automatically.",
"zones.roomSensorEntity": "Room sensor entity_id",
"zones.roomSensorWeight": "Room sensor weight %",
"zones.maxDifference": "Max. sensor difference °C",
"zones.sensorHelp": "This sensor belongs only to this zone. If it becomes unavailable, the controller automatically falls back to the GREE sensor.",
"zones.greeTemp": "GREE",
"zones.externalTemp": "Room",
"zones.usedSource": "used",
"zones.sourceDevice": "GREE",
"zones.sourceExternal": "room sensor",
"zones.sourceCombined": "combined",
"zones.sourceFallback": "GREE fallback",
"zones.sourceDiscrepancy": "GREE fallback: sensor mismatch",
"zones.sourceUnavailable": "unavailable",
"schedules.calendar": "Calendar",
"schedules.weekdays": "Weekdays",
"schedules.crossMidnight": "Intervals crossing midnight are supported",
"schedules.emptyTitle": "No schedules",
"schedules.emptyText": "Set a temperature by day and time.",
"automations.rules": "Rules",
"automations.namePlaceholder": "Emergency cooling",
"automations.trigger": "Trigger",
"automations.tempAbove": "Temperature above",
"automations.tempBelow": "Temperature below",
"automations.time": "Time",
"automations.measurementDevice": "Measurement device",
"automations.thresholdC": "Threshold °C",
"automations.action": "Action",
"automations.triggerAt": "at {time}",
"automations.triggerAbove": "above {temperature}",
"automations.triggerBelow": "below {temperature}",
"automations.triggerSummary": "Trigger {trigger} · action: {device}",
"automations.last": "Last",
"automations.emptyTitle": "No automations",
"automations.emptyText": "Add a temperature or time rule.",
"history.measurements": "Measurements",
"history.title": "Temperature history",
"history.6h": "6 hours",
"history.24h": "24 hours",
"history.7d": "7 days",
"history.30d": "30 days",
"history.noData": "No measurements for the selected period",
"settings.system": "System",
"settings.controller": "Controller",
"settings.clientId": "Client identifier",
"settings.pollInterval": "Poll interval (s)",
"settings.zoneInterval": "Zone interval (s)",
"settings.broadcast": "Broadcast address",
"settings.discoveryTimeout": "Discovery timeout (ms)",
"settings.simulationMode": "Simulation mode",
"settings.haTokenKeep": "Leave empty to keep the saved token",
"settings.haTokenSaved": "Token saved — leave empty to keep it",
"settings.haLongLivedToken": "Long-Lived Access Token",
"settings.defaultEntity": "Default entity_id",
"settings.testHa": "Test HA",
"settings.systemState": "System status",
"settings.version": "Version",
"settings.uptime": "Uptime",
"settings.apiAuth": "API authentication",
"settings.enabled": "enabled",
"settings.disabled": "disabled",
"logs.diagnostics": "Diagnostics",
"logs.emptyTitle": "No events",
"logs.emptyText": "The event log is empty.",
"common.name": "Name",
"common.zone": "Zone",
"common.device": "Device",
"common.mode": "Mode",
"common.target": "Target",
"common.targetC": "Target °C",
"common.temperature": "Temperature",
"common.temperatureC": "Temperature °C",
"common.schedule": "Schedule",
"common.automation": "Automation",
"common.enabled": "Enabled",
"common.disabled": "Disabled",
"common.active": "Active",
"common.from": "From",
"common.to": "To",
"common.power": "Power",
"common.on": "ON",
"common.off": "OFF",
"common.noDevice": "No device",
"common.noZone": "No zone",
"common.cooldown": "Cooldown",
"common.cooldownSeconds": "Cooldown (s)",
"common.removed": "Removed",
"common.updated": "Data refreshed",
"common.saved": "Saved",
"placeholder.livingRoom": "Living room",
"placeholder.night": "Night",
"mode.auto": "Auto",
"mode.cool": "Cooling",
"mode.dry": "Dry",
"mode.fan": "Fan",
"mode.heat": "Heating",
"fan.auto": "Auto",
"fan.low": "Low",
"fan.mediumLow": "Medium-low",
"fan.medium": "Medium",
"fan.mediumHigh": "Medium-high",
"fan.high": "High",
"auth.required": "Authentication required",
"auth.token": "Access token",
"auth.connect": "Connect",
"auth.invalid": "Invalid access token",
"error.http": "HTTP error {status}",
"confirm.delete": "Delete {label}?",
"label.device": "device",
"label.zone": "zone",
"label.schedule": "schedule",
"label.automation": "automation",
"toast.found": "Found: {count}",
"toast.haTemperature": "Home Assistant: {temperature}°C",
"day.1": "Mon",
"day.2": "Tue",
"day.3": "Wed",
"day.4": "Thu",
"day.5": "Fri",
"day.6": "Sat",
"day.7": "Sun",
"settings.haSensorInput": "Home Assistant sensor input",
"settings.haSensorInputHint": "Optional. Used only when a room zone reads an external Home Assistant temperature sensor.",
"settings.haIntegrationAccess": "Home Assistant integration access",
"settings.haIntegrationHint": "Create a controller token and paste it into the GREE Controller integration in Home Assistant.",
"settings.newToken": "Create new token",
"settings.noTokens": "No integration tokens",
"settings.noTokensHint": "Create a token before adding GREE Controller to Home Assistant.",
"settings.tokenCreated": "Token created",
"settings.tokenCreatedHint": "Copy this token now. It will not be shown again.",
"settings.created": "Created",
"actions.copy": "Copy",
"actions.revoke": "Revoke",
"actions.done": "Done",
"confirm.revokeToken": "Revoke this Home Assistant access token?",
"toast.tokenCreated": "Home Assistant access token created",
"toast.tokenCopied": "Token copied",
"toast.tokenRevoked": "Token revoked"
}
}
+231
View File
@@ -0,0 +1,231 @@
{
"meta": {
"code": "pl",
"name": "Polish",
"native_name": "Polski",
"locale": "pl-PL"
},
"translations": {
"meta.description": "Lokalny sterownik klimatyzatorów GREE",
"status.connecting": "Łączenie…",
"status.connected": "Połączono",
"status.disconnected": "Rozłączono",
"status.connectionError": "Błąd połączenia",
"status.online": "Online",
"status.offline": "Offline",
"controls.language": "Język",
"controls.theme": "Motyw",
"theme.system": "System",
"theme.light": "Jasny",
"theme.dark": "Ciemny",
"nav.navigation": "Nawigacja",
"nav.dashboard": "Pulpit",
"nav.devices": "Urządzenia",
"nav.zones": "Strefy",
"nav.history": "Historia",
"nav.more": "Więcej",
"nav.schedules": "Harmonogramy",
"nav.automations": "Automatyzacje",
"nav.settings": "Ustawienia",
"nav.logs": "Zdarzenia",
"nav.logsAndEvents": "Zdarzenia i logi",
"actions.refresh": "Odśwież",
"actions.discover": "Wykryj",
"actions.discovering": "Szukam…",
"actions.add": "Dodaj",
"actions.show": "Pokaż",
"actions.save": "Zapisz",
"actions.cancel": "Anuluj",
"actions.edit": "Edytuj",
"actions.delete": "Usuń",
"actions.read": "Odczyt",
"actions.bind": "Bind",
"actions.noChange": "Bez zmiany",
"actions.turnOn": "Włącz",
"actions.turnOff": "Wyłącz",
"dashboard.home": "Dom",
"dashboard.title": "Komfort pod kontrolą",
"dashboard.loading": "Wczytywanie urządzeń…",
"dashboard.quickControl": "Szybkie sterowanie",
"dashboard.summary": "{online} z {total} urządzeń online{active}.",
"dashboard.summaryActive": ", {count} pracuje",
"dashboard.empty": "Brak urządzeń. Uruchom wykrywanie lub dodaj urządzenie ręcznie.",
"dashboard.metricOnline": "Online",
"dashboard.metricActive": "Aktywne",
"dashboard.metricDemand": "Strefy z żądaniem",
"devices.lan": "Sieć LAN",
"devices.addManual": "Dodaj ręcznie",
"devices.add": "Dodaj urządzenie",
"devices.ipAddress": "Adres IP",
"devices.protocol": "Protokół",
"devices.keyOptional": "Klucz (opcjonalnie)",
"devices.keyPlaceholder": "Zostaw puste — bind automatyczny",
"devices.simulated": "Urządzenie symulowane",
"devices.simulator": "symulator",
"devices.power": "Zasilanie",
"devices.currentTemperature": "Temperatura",
"devices.outdoor": "na zewnątrz",
"devices.quiet": "Cichy",
"devices.swing": "Swing",
"devices.turbo": "Turbo",
"devices.emptyTitle": "Brak urządzeń",
"devices.emptyText": "Użyj przycisku Wykryj w tej samej sieci LAN.",
"devices.readDone": "Odczyt zakończony",
"devices.bound": "Urządzenie powiązane",
"devices.added": "Urządzenie dodane",
"zones.automation": "Automatyka",
"zones.new": "Nowa strefa",
"zones.description": "Strefa steruje urządzeniem według temperatury, histerezy i minimalnego czasu cyklu.",
"zones.hysteresis": "Histereza °C",
"zones.source": "Strategia temperatury",
"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.greeSource": "sensor GREE",
"zones.combinedSource": "GREE + czujnik pokojowy",
"zones.externalSource": "czujnik pokojowy",
"zones.measurement": "Temperatura sterująca",
"zones.demand": "Żądanie",
"zones.emptyTitle": "Brak stref",
"zones.emptyText": "Dodaj strefę, aby sterować temperaturą automatycznie.",
"zones.roomSensorEntity": "entity_id czujnika pokojowego",
"zones.roomSensorWeight": "Waga czujnika pokojowego %",
"zones.maxDifference": "Maks. różnica czujników °C",
"zones.sensorHelp": "Ten czujnik jest przypisany tylko do tej strefy. Gdy przestanie być dostępny, kontroler automatycznie wróci do sensora GREE.",
"zones.greeTemp": "GREE",
"zones.externalTemp": "Pokój",
"zones.usedSource": "użyte",
"zones.sourceDevice": "GREE",
"zones.sourceExternal": "czujnik pokojowy",
"zones.sourceCombined": "połączona",
"zones.sourceFallback": "fallback GREE",
"zones.sourceDiscrepancy": "fallback GREE: rozbieżność",
"zones.sourceUnavailable": "niedostępna",
"schedules.calendar": "Kalendarz",
"schedules.weekdays": "Dni tygodnia",
"schedules.crossMidnight": "Obsługa przedziałów przez północ",
"schedules.emptyTitle": "Brak harmonogramów",
"schedules.emptyText": "Ustaw temperaturę zależnie od dnia i godziny.",
"automations.rules": "Reguły",
"automations.namePlaceholder": "Chłodzenie awaryjne",
"automations.trigger": "Wyzwalacz",
"automations.tempAbove": "Temperatura powyżej",
"automations.tempBelow": "Temperatura poniżej",
"automations.time": "Godzina",
"automations.measurementDevice": "Urządzenie pomiarowe",
"automations.thresholdC": "Próg °C",
"automations.action": "Akcja",
"automations.triggerAt": "o {time}",
"automations.triggerAbove": "powyżej {temperature}",
"automations.triggerBelow": "poniżej {temperature}",
"automations.triggerSummary": "Wyzwalacz {trigger} · akcja: {device}",
"automations.last": "Ostatnio",
"automations.emptyTitle": "Brak automatyzacji",
"automations.emptyText": "Dodaj regułę temperatury lub czasu.",
"history.measurements": "Pomiary",
"history.title": "Historia temperatury",
"history.6h": "6 godzin",
"history.24h": "24 godziny",
"history.7d": "7 dni",
"history.30d": "30 dni",
"history.noData": "Brak pomiarów dla wybranego okresu",
"settings.system": "System",
"settings.controller": "Kontroler",
"settings.clientId": "Identyfikator klienta",
"settings.pollInterval": "Interwał odczytu (s)",
"settings.zoneInterval": "Interwał stref (s)",
"settings.broadcast": "Adres broadcast",
"settings.discoveryTimeout": "Timeout wykrywania (ms)",
"settings.simulationMode": "Tryb symulacji",
"settings.haTokenKeep": "Pozostaw puste, aby zachować zapisany token",
"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.systemState": "Stan systemu",
"settings.version": "Wersja",
"settings.uptime": "Czas pracy",
"settings.apiAuth": "Autoryzacja API",
"settings.enabled": "włączona",
"settings.disabled": "wyłączona",
"logs.diagnostics": "Diagnostyka",
"logs.emptyTitle": "Brak zdarzeń",
"logs.emptyText": "Log jest pusty.",
"common.name": "Nazwa",
"common.zone": "Strefa",
"common.device": "Urządzenie",
"common.mode": "Tryb",
"common.target": "Cel",
"common.targetC": "Cel °C",
"common.temperature": "Temperatura",
"common.temperatureC": "Temperatura °C",
"common.schedule": "Harmonogram",
"common.automation": "Automatyzacja",
"common.enabled": "Aktywna",
"common.disabled": "Wyłączona",
"common.active": "Aktywna",
"common.from": "Od",
"common.to": "Do",
"common.power": "Zasilanie",
"common.on": "WŁ.",
"common.off": "WYŁ.",
"common.noDevice": "Brak urządzenia",
"common.noZone": "Brak strefy",
"common.cooldown": "Przerwa",
"common.cooldownSeconds": "Przerwa (s)",
"common.removed": "Usunięto",
"common.updated": "Dane odświeżone",
"common.saved": "Zapisano",
"placeholder.livingRoom": "Salon",
"placeholder.night": "Noc",
"mode.auto": "Auto",
"mode.cool": "Chłodzenie",
"mode.dry": "Osuszanie",
"mode.fan": "Nawiew",
"mode.heat": "Grzanie",
"fan.auto": "Auto",
"fan.low": "Niski",
"fan.mediumLow": "Śr.-niski",
"fan.medium": "Średni",
"fan.mediumHigh": "Śr.-wysoki",
"fan.high": "Wysoki",
"auth.required": "Wymagane uwierzytelnienie",
"auth.token": "Token dostępu",
"auth.connect": "Połącz",
"auth.invalid": "Nieprawidłowy token dostępu",
"error.http": "Błąd HTTP {status}",
"confirm.delete": "Usunąć {label}?",
"label.device": "urządzenie",
"label.zone": "strefę",
"label.schedule": "harmonogram",
"label.automation": "automatyzację",
"toast.found": "Znaleziono: {count}",
"toast.haTemperature": "Home Assistant: {temperature}°C",
"day.1": "Pn",
"day.2": "Wt",
"day.3": "Śr",
"day.4": "Cz",
"day.5": "Pt",
"day.6": "So",
"day.7": "Nd",
"settings.haSensorInput": "Źródło czujników Home Assistant",
"settings.haSensorInputHint": "Opcjonalne. Używane tylko wtedy, gdy strefa korzysta z zewnętrznego czujnika temperatury z Home Assistant.",
"settings.haIntegrationAccess": "Dostęp integracji Home Assistant",
"settings.haIntegrationHint": "Utwórz token kontrolera i wklej go w integracji GREE Controller w Home Assistant.",
"settings.newToken": "Utwórz nowy token",
"settings.noTokens": "Brak tokenów integracji",
"settings.noTokensHint": "Utwórz token przed dodaniem GREE Controller do Home Assistant.",
"settings.tokenCreated": "Token utworzony",
"settings.tokenCreatedHint": "Skopiuj token teraz. Nie będzie można wyświetlić go ponownie.",
"settings.created": "Utworzono",
"actions.copy": "Kopiuj",
"actions.revoke": "Unieważnij",
"actions.done": "Gotowe",
"confirm.revokeToken": "Unieważnić ten token dostępu Home Assistant?",
"toast.tokenCreated": "Utworzono token dostępu Home Assistant",
"toast.tokenCopied": "Token skopiowany",
"toast.tokenRevoked": "Token unieważniony"
}
}
+14
View File
@@ -0,0 +1,14 @@
# Operational scripts
All operator-facing scripts live in this directory.
- `install.sh` — first installation on a Debian/Ubuntu systemd host or LXC container.
- `update.sh` — safe in-place update with SQLite/config/binary backup and automatic rollback on failed health check.
- `service.sh` — start, stop, restart, status, logs and health helper.
- `dev.sh` — development build/run/check workflow.
- `smoke.sh` — HTTP/API smoke test used by `dev.sh --check`.
- `generate_ha_migration.py` — legacy/manual Home Assistant entity mapping helper.
- `install-lxc.sh` — compatibility alias for `install.sh`.
- `common.sh` — shared shell functions; normally not executed directly.
`build.rs` remains in the package root because Cargo requires the build script at that location; it is not an operator script.
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Shared helpers for GREE Controller operational scripts.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SERVICE_NAME="gree-controller.service"
SERVICE_USER="gree-controller"
SERVICE_GROUP="gree-controller"
INSTALL_DIR="/opt/gree-controller"
INSTALL_BINARY="$INSTALL_DIR/gree-controller"
DATA_DIR="/var/lib/gree-controller"
ENV_FILE="/etc/gree-controller.env"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME"
BACKUP_ROOT="/var/backups/gree-controller"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
require_root() {
[[ ${EUID:-$(id -u)} -eq 0 ]] || fail "Run this script as root (for example: sudo $0)."
}
require_systemd() {
command -v systemctl >/dev/null 2>&1 || fail "systemd/systemctl is required for the LXC installation."
}
run_privileged() {
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
fail "Root/sudo privileges are required: $*"
fi
}
install_build_dependencies() {
local missing=0
for cmd in curl cc make pkg-config; do
command -v "$cmd" >/dev/null 2>&1 || missing=1
done
command -v python3 >/dev/null 2>&1 || missing=1
[[ "$missing" -eq 1 ]] || return 0
if command -v apt-get >/dev/null 2>&1; then
say "Installing build dependencies"
run_privileged apt-get update
run_privileged apt-get install -y --no-install-recommends \
build-essential curl ca-certificates pkg-config python3
elif command -v dnf >/dev/null 2>&1; then
say "Installing build dependencies"
run_privileged dnf install -y gcc gcc-c++ make curl ca-certificates pkgconf-pkg-config python3
elif command -v apk >/dev/null 2>&1; then
say "Installing build dependencies"
run_privileged apk add --no-cache build-base curl ca-certificates pkgconf python3
else
fail "Unsupported package manager. Install a C compiler, make, curl, pkg-config, Python 3 and CA certificates."
fi
}
ensure_rust() {
if command -v cargo >/dev/null 2>&1; then
return 0
fi
command -v curl >/dev/null 2>&1 || fail "curl is required to install Rust."
say "Installing stable Rust with rustup"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH"
command -v cargo >/dev/null 2>&1 || fail "Cargo is unavailable after rustup installation."
}
project_version() {
awk '
/^\[package\]/ { package=1; next }
/^\[/ && package { exit }
package && /^version[[:space:]]*=/ {
gsub(/.*=[[:space:]]*"|".*/, "", $0); print; exit
}
' "$PROJECT_ROOT/Cargo.toml"
}
read_env_value() {
local key="$1" default_value="${2:-}" value=""
if [[ -f "$ENV_FILE" ]]; then
value="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true)"
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
fi
printf '%s' "${value:-$default_value}"
}
health_url() {
local bind port
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
port="${bind##*:}"
printf 'http://127.0.0.1:%s/api/health' "$port"
}
database_path() {
local db
db="$(read_env_value GREE_CONTROLLER_DATABASE "$DATA_DIR/gree-controller.db")"
if [[ "$db" != /* ]]; then
db="$DATA_DIR/$db"
fi
printf '%s' "$db"
}
panel_url() {
local bind port ip
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
port="${bind##*:}"
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
printf 'http://%s:%s' "${ip:-LXC_ADDRESS}" "$port"
}
wait_for_health() {
local url attempts="${1:-60}"
url="$(health_url)"
for _ in $(seq 1 "$attempts"); do
if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
return 1
}
backup_timestamp() {
date -u +'%Y%m%dT%H%M%SZ'
}
Executable
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
cd "$PROJECT_ROOT"
MODE="debug"
ACTION="run"
INSTALL_DEPS=1
RESET_DB=0
HOST=""
PORT=""
usage() {
cat <<'TXT'
GREE Controller - development environment
Usage:
./scripts/dev.sh install missing tools, build and run
./scripts/dev.sh --release run an optimized build
./scripts/dev.sh --check formatting, tests, build and API smoke test
./scripts/dev.sh --reset remove the local database before startup
./scripts/dev.sh --no-install do not install system packages or Rust
./scripts/dev.sh --host 0.0.0.0 --port 8787
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--release) MODE="release"; shift ;;
--check) ACTION="check"; shift ;;
--reset) RESET_DB=1; shift ;;
--no-install) INSTALL_DEPS=0; shift ;;
--host) HOST="${2:?missing value for --host}"; shift 2 ;;
--port) PORT="${2:?missing value for --port}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$INSTALL_DEPS" -eq 1 ]]; then
install_build_dependencies
ensure_rust
else
command -v cargo >/dev/null 2>&1 || fail "Cargo is not installed and --no-install was requested."
fi
if [[ ! -f .env ]]; then
cp .env.example .env
say "Created .env from the example configuration"
fi
mkdir -p data
if [[ "$RESET_DB" -eq 1 ]]; then
rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal
say "Removed the local database"
fi
set -a
# shellcheck disable=SC1091
source ./.env
set +a
if [[ -n "$HOST" || -n "$PORT" ]]; then
current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
current_host="${current%:*}"
current_port="${current##*:}"
export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}"
fi
if [[ "$ACTION" == "check" ]]; then
say "Checking formatting"
cargo fmt --all -- --check
say "Running Rust tests"
cargo test --all-targets
say "Building the application"
cargo build
say "Running HTTP/API smoke test"
"$SCRIPT_DIR/smoke.sh"
say "All checks passed"
exit 0
fi
if [[ "$MODE" == "release" ]]; then
say "Building release version"
cargo build --release
BINARY="$PROJECT_ROOT/target/release/gree-controller"
else
say "Building debug version"
cargo build
BINARY="$PROJECT_ROOT/target/debug/gree-controller"
fi
bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
display_host="${bind%:*}"
[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1"
say "Panel: http://${display_host}:${bind##*:}"
say "Stop: Ctrl+C"
exec "$BINARY"
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Generate a Home Assistant entity-ID takeover mapping for GREE Controller."""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
ENTITY_RE = re.compile(r"^climate\.[a-z0-9_]+$")
def request_json(url: str, token: str = "") -> Any:
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
except urllib.error.HTTPError as err:
detail = err.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {err.code}: {detail or err.reason}") from err
except urllib.error.URLError as err:
raise RuntimeError(f"Connection failed: {err.reason}") from err
def normalize_url(value: str) -> str:
value = value.strip().rstrip("/")
parsed = urllib.parse.urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"Invalid URL: {value}")
return value
def parse_mapping(value: str) -> tuple[str, str]:
if "=" not in value:
raise argparse.ArgumentTypeError("mapping must be ENTITY_ID=CONTROLLER_DEVICE_ID")
entity_id, device_id = (part.strip() for part in value.split("=", 1))
if not ENTITY_RE.fullmatch(entity_id):
raise argparse.ArgumentTypeError(f"invalid climate entity ID: {entity_id}")
if not device_id:
raise argparse.ArgumentTypeError("controller device ID cannot be empty")
return entity_id, device_id
def validate_ha_entity(base_url: str, token: str, entity_id: str) -> dict[str, Any]:
encoded = urllib.parse.quote(entity_id, safe="")
state = request_json(f"{base_url}/api/states/{encoded}", token)
if not isinstance(state, dict) or state.get("entity_id") != entity_id:
raise RuntimeError(f"Home Assistant did not return {entity_id}")
return state
def controller_devices(base_url: str, token: str) -> list[dict[str, Any]]:
data = request_json(f"{base_url}/api/integrations/home-assistant/devices", token)
if not isinstance(data, list):
raise RuntimeError("Controller returned an invalid devices response")
return [item for item in data if isinstance(item, dict) and item.get("id")]
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Generate /config/gree_controller_entities.json so the custom Home Assistant "
"integration can claim an existing climate entity ID after the old integration is unloaded."
)
)
parser.add_argument("--map", action="append", default=[], type=parse_mapping, metavar="ENTITY=DEVICE", help="Repeatable mapping, e.g. climate.klima_salon=gree-aabbccddeeff")
parser.add_argument("--entity", help="Single existing HA climate entity, e.g. climate.klima_salon")
parser.add_argument("--device", help="Controller device ID for --entity")
parser.add_argument("--controller-url", help="Optional controller URL used to validate or auto-select a single device")
parser.add_argument("--controller-token", default="", help="GREE Controller Home Assistant access token")
parser.add_argument("--ha-url", help="Optional Home Assistant URL used to validate source entities")
parser.add_argument("--ha-token", default="", help="Optional Home Assistant Long-Lived Access Token")
parser.add_argument("--output", default="home-assistant/generated/gree_controller_entities.json", help="Output mapping file")
args = parser.parse_args()
mappings: list[tuple[str, str]] = list(args.map)
devices: list[dict[str, Any]] = []
controller_url = normalize_url(args.controller_url) if args.controller_url else ""
ha_url = normalize_url(args.ha_url) if args.ha_url else ""
if controller_url:
devices = controller_devices(controller_url, args.controller_token)
if args.entity:
if not ENTITY_RE.fullmatch(args.entity):
parser.error("--entity must be a climate.* entity ID using lowercase letters, digits and underscores")
device_id = (args.device or "").strip()
if not device_id:
if len(devices) == 1:
device_id = str(devices[0]["id"])
elif not controller_url:
parser.error("--device is required unless --controller-url identifies exactly one device")
else:
choices = ", ".join(f"{item['id']} ({item.get('name', 'unnamed')})" for item in devices) or "none"
parser.error(f"--device is required because the controller exposes {len(devices)} devices: {choices}")
mappings.append((args.entity, device_id))
if not mappings:
parser.error("provide at least one --map or --entity")
by_entity: dict[str, str] = {}
by_device: dict[str, str] = {}
for entity_id, device_id in mappings:
if entity_id in by_entity and by_entity[entity_id] != device_id:
parser.error(f"duplicate entity mapping: {entity_id}")
if device_id in by_device and by_device[device_id] != entity_id:
parser.error(f"one controller device cannot claim two climate entity IDs: {device_id}")
by_entity[entity_id] = device_id
by_device[device_id] = entity_id
if devices:
valid_ids = {str(item["id"]) for item in devices}
missing = [device_id for device_id in by_device if device_id not in valid_ids]
if missing:
parser.error(f"controller device ID not found: {', '.join(missing)}")
source_metadata: dict[str, dict[str, Any]] = {}
if ha_url:
if not args.ha_token:
parser.error("--ha-token is required when --ha-url is used")
for entity_id in by_entity:
state = validate_ha_entity(ha_url, args.ha_token, entity_id)
source_metadata[entity_id] = {
"friendly_name": state.get("attributes", {}).get("friendly_name", ""),
"state": state.get("state"),
}
payload = {
"version": 1,
"entities": [
{
"entity_id": entity_id,
"device_id": device_id,
**({"source": source_metadata[entity_id]} if entity_id in source_metadata else {}),
}
for entity_id, device_id in sorted(by_entity.items())
],
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"Generated: {output}")
print("Copy this file to Home Assistant as /config/gree_controller_entities.json.")
print("Before adding GREE Controller in HA, verify the Rust controller can control the AC, then disable/remove the old GREE integration so the requested entity IDs are free.")
print("Automations and dashboards that reference the same entity_id can then continue using it.")
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "install-lxc.sh is kept as a compatibility alias; using scripts/install.sh." >&2
exec "$SCRIPT_DIR/install.sh" "$@"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
cd "$PROJECT_ROOT"
RUN_TESTS=1
START_SERVICE=1
usage() {
cat <<'TXT'
Install GREE Controller as a systemd service in a Debian/Ubuntu LXC container.
Usage:
sudo ./scripts/install.sh
sudo ./scripts/install.sh --skip-tests
sudo ./scripts/install.sh --no-start
The installer preserves an existing /etc/gree-controller.env file and database.
Use scripts/update.sh for later releases.
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-tests) RUN_TESTS=0; shift ;;
--no-start) START_SERVICE=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
require_root
require_systemd
if [[ -x "$INSTALL_BINARY" && -f "$SERVICE_FILE" ]]; then
fail "An existing installation was detected. Use sudo ./scripts/update.sh instead."
fi
install_build_dependencies
ensure_rust
if command -v systemd-detect-virt >/dev/null 2>&1; then
virt="$(systemd-detect-virt --container 2>/dev/null || true)"
[[ -n "$virt" ]] || warn "No container runtime was detected. Installation can still continue on a regular systemd host."
fi
version="$(project_version)"
say "Installing GREE Controller ${version:-unknown}"
if [[ "$RUN_TESTS" -eq 1 ]]; then
say "Running Rust tests before installation"
cargo test --all-targets
fi
say "Building release binary"
cargo build --release
getent group "$SERVICE_GROUP" >/dev/null 2>&1 || groupadd --system "$SERVICE_GROUP"
id "$SERVICE_USER" >/dev/null 2>&1 || \
useradd --system --gid "$SERVICE_GROUP" --home "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$DATA_DIR"
install -d -o root -g root -m 0755 "$INSTALL_DIR"
install -d -o root -g root -m 0700 "$BACKUP_ROOT"
install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY"
install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE"
if [[ ! -f "$ENV_FILE" ]]; then
token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
cat > "$ENV_FILE" <<ENV
GREE_CONTROLLER_BIND=0.0.0.0:8787
GREE_CONTROLLER_DATABASE=$DATA_DIR/gree-controller.db
GREE_CONTROLLER_APP_TOKEN=$token
GREE_CONTROLLER_SIMULATE=true
GREE_CONTROLLER_AUTO_SEED=true
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
GREE_CONTROLLER_ID=gree-controller
RUST_LOG=info,tower_http=info
HA_URL=
HA_TOKEN=
HA_ENTITY_ID=
ENV
chmod 0600 "$ENV_FILE"
say "Generated administrator token and saved it to $ENV_FILE"
printf 'Administrator token: %s\n' "$token"
else
say "Preserving existing $ENV_FILE"
fi
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null
if [[ "$START_SERVICE" -eq 1 ]]; then
say "Starting GREE Controller"
systemctl restart "$SERVICE_NAME"
if ! wait_for_health 80; then
systemctl --no-pager --full status "$SERVICE_NAME" || true
journalctl -u "$SERVICE_NAME" -n 80 --no-pager || true
fail "Service did not pass the health check."
fi
say "Installation complete"
printf 'Panel: %s\n' "$(panel_url)"
printf 'Status: %s status\n' "$SCRIPT_DIR/service.sh"
printf 'Logs: %s logs\n' "$SCRIPT_DIR/service.sh"
else
say "Installation complete; service was not started (--no-start)."
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
action="${1:-status}"
case "$action" in
start|stop|restart)
require_root
systemctl "$action" "$SERVICE_NAME"
;;
status)
systemctl --no-pager --full status "$SERVICE_NAME"
;;
logs)
exec journalctl -u "$SERVICE_NAME" -f
;;
health)
curl -fsS "$(health_url)"; printf '\n'
;;
*)
echo "Usage: $0 {start|stop|restart|status|logs|health}" >&2
exit 2
;;
esac
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
BINARY="${GREE_CONTROLLER_TEST_BINARY:-$ROOT/target/debug/gree-controller}"
[[ -x "$BINARY" ]] || cargo build >/dev/null
TMP="$(mktemp -d)"
PORT="${GREE_CONTROLLER_TEST_PORT:-$((19000 + RANDOM % 1000))}"
LOG="$TMP/server.log"
PID=""
cleanup() {
if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then kill "$PID" 2>/dev/null || true; wait "$PID" 2>/dev/null || true; fi
rm -rf "$TMP"
}
trap cleanup EXIT
GREE_CONTROLLER_BIND="127.0.0.1:$PORT" \
GREE_CONTROLLER_DATABASE="$TMP/test.db" \
GREE_CONTROLLER_SIMULATE=true \
GREE_CONTROLLER_AUTO_SEED=true \
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=2 \
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=2 \
GREE_CONTROLLER_APP_TOKEN="" \
RUST_LOG=warn \
"$BINARY" >"$LOG" 2>&1 &
PID=$!
for _ in $(seq 1 80); do
if curl -fsS "http://127.0.0.1:$PORT/api/health" >"$TMP/health.json"; then break; fi
if ! kill -0 "$PID" 2>/dev/null; then cat "$LOG" >&2; exit 1; fi
sleep 0.1
done
grep -q '"status":"ok"' "$TMP/health.json"
curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json"
grep -q 'sim-salon' "$TMP/bootstrap.json"
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"power":true,"mode":"cool","target_temperature":22}' \
"http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/command.json"
grep -q '"power":true' "$TMP/command.json"
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"name":"Test","device_id":"sim-salon","enabled":true,"mode":"cool","setpoint":23,"hysteresis":0.6,"min_on_seconds":0,"min_off_seconds":0,"sensor_source":"device"}' \
"http://127.0.0.1:$PORT/api/zones" >"$TMP/zone.json"
grep -q '"name":"Test"' "$TMP/zone.json"
curl -fsS "http://127.0.0.1:$PORT/api/readings?device_id=sim-salon&hours=1" >"$TMP/readings.json"
grep -q '"readings"' "$TMP/readings.json"
# Home Assistant gets its own generated, restricted controller token.
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"name":"Smoke Home Assistant"}' \
"http://127.0.0.1:$PORT/api/access-tokens" >"$TMP/access-token.json"
HA_ACCESS_TOKEN="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["token"])' "$TMP/access-token.json")"
[[ "$HA_ACCESS_TOKEN" == gree_controller_* ]]
if curl -fsS "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices" >/dev/null 2>&1; then
echo "Restricted Home Assistant API unexpectedly accepted a request without a token" >&2
exit 1
fi
curl -fsS -H "Authorization: Bearer $HA_ACCESS_TOKEN" \
"http://127.0.0.1:$PORT/api/integrations/home-assistant/devices" >"$TMP/ha-devices.json"
grep -q 'sim-salon' "$TMP/ha-devices.json"
curl -fsS -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"power":false}' \
"http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command" >"$TMP/ha-command.json"
grep -q '"power":false' "$TMP/ha-command.json"
echo "Smoke test OK (port $PORT)"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
cd "$PROJECT_ROOT"
RUN_TESTS=1
usage() {
cat <<'TXT'
Update an existing systemd/LXC GREE Controller installation from this source tree.
Usage:
sudo ./scripts/update.sh
sudo ./scripts/update.sh --skip-tests
The updater:
1. builds and tests the new release while the old service is still running,
2. stops the service,
3. backs up the binary, systemd unit, environment file and SQLite database,
4. installs the new binary and unit,
5. restarts and checks /api/health,
6. rolls back the binary/unit/database automatically if startup fails.
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-tests) RUN_TESTS=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
require_root
require_systemd
[[ -x "$INSTALL_BINARY" ]] || fail "No installed binary at $INSTALL_BINARY. Run scripts/install.sh first."
[[ -f "$SERVICE_FILE" ]] || fail "No installed systemd unit at $SERVICE_FILE. Run scripts/install.sh first."
install_build_dependencies
ensure_rust
version="$(project_version)"
say "Preparing update to GREE Controller ${version:-unknown}"
if [[ "$RUN_TESTS" -eq 1 ]]; then
say "Running Rust tests before touching the running service"
cargo test --all-targets
fi
say "Building release binary"
cargo build --release
stamp="$(backup_timestamp)"
backup_dir="$BACKUP_ROOT/$stamp"
install -d -o root -g root -m 0700 "$backup_dir"
db_path="$(database_path)"
rollback() {
local rc=$?
trap - ERR
set +e
warn "Update failed; restoring previous installation from $backup_dir"
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
if [[ -f "$backup_dir/gree-controller.binary" ]]; then
install -o root -g root -m 0755 "$backup_dir/gree-controller.binary" "$INSTALL_BINARY"
fi
if [[ -f "$backup_dir/gree-controller.service" ]]; then
install -o root -g root -m 0644 "$backup_dir/gree-controller.service" "$SERVICE_FILE"
fi
if [[ -f "$backup_dir/gree-controller.env" ]]; then
install -o root -g root -m 0600 "$backup_dir/gree-controller.env" "$ENV_FILE"
fi
if [[ -f "$backup_dir/gree-controller.db" ]]; then
install -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0640 "$backup_dir/gree-controller.db" "$db_path"
fi
rm -f "${db_path}-wal" "${db_path}-shm"
systemctl daemon-reload
systemctl start "$SERVICE_NAME" >/dev/null 2>&1 || true
exit "$rc"
}
trap rollback ERR
say "Stopping service for a consistent SQLite backup"
systemctl stop "$SERVICE_NAME"
cp -a "$INSTALL_BINARY" "$backup_dir/gree-controller.binary"
cp -a "$SERVICE_FILE" "$backup_dir/gree-controller.service"
[[ -f "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$backup_dir/gree-controller.env"
if [[ -f "$db_path" ]]; then
cp -a "$db_path" "$backup_dir/gree-controller.db"
fi
say "Backup created: $backup_dir"
say "Installing new release binary"
install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY.new"
mv -f "$INSTALL_BINARY.new" "$INSTALL_BINARY"
install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE"
systemctl daemon-reload
systemctl start "$SERVICE_NAME"
if ! wait_for_health 80; then
journalctl -u "$SERVICE_NAME" -n 100 --no-pager || true
false
fi
trap - ERR
say "Update complete"
printf 'Version: %s\n' "${version:-unknown}"
printf 'Panel: %s\n' "$(panel_url)"
printf 'Backup: %s\n' "$backup_dir"
+689
View File
@@ -0,0 +1,689 @@
use std::{net::IpAddr, time::Duration};
use axum::{
body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
http::{header, HeaderValue, StatusCode},
middleware::{self, Next},
response::{Html, Response},
routing::{get, post},
Json, Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{Duration as ChronoDuration, Utc};
use futures_util::StreamExt;
use rand::{rngs::OsRng, RngCore};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use serde_json::{json, Value};
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
use uuid::Uuid;
use crate::{
engine,
error::AppError,
home_assistant,
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone},
protocol::merge_discovered,
state::AppState,
};
const INDEX_HTML: &str = include_str!("../web/index.html");
const APP_JS: &str = include_str!("../web/app.js");
const STYLES_CSS: &str = include_str!("../web/styles.css");
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
const FAVICON: &str = include_str!("../web/favicon.svg");
include!(concat!(env!("OUT_DIR"), "/languages.rs"));
pub fn router(state: AppState) -> Router {
let protected = Router::new()
.route("/api/bootstrap", get(bootstrap))
.route("/api/system/info", get(system_info))
.route("/api/discovery", post(discover))
.route("/api/devices", get(list_devices).post(add_device))
.route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device))
.route("/api/devices/:id/bind", post(bind_device))
.route("/api/devices/:id/poll", post(poll_device))
.route("/api/devices/:id/command", post(command_device))
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
.route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
.route("/api/automations", get(list_automations).post(create_automation))
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
.route("/api/readings", get(readings))
.route("/api/events", get(events))
.route("/api/settings", get(get_settings).put(update_settings))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
Router::new()
.route("/api/health", get(health))
.route("/ws", get(websocket))
.route("/", get(index))
.route("/index.html", get(index))
.route("/app.js", get(app_js))
.route("/styles.css", get(styles_css))
.route("/manifest.webmanifest", get(manifest))
.route("/sw.js", get(service_worker))
.route("/favicon.svg", get(favicon))
.route("/lang/index.json", get(language_index))
.route("/lang/:file", get(language_file))
.merge(protected)
.merge(home_assistant_api)
.fallback(index)
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> {
let expected = state.config.app_token.trim();
if expected.is_empty() {
return Ok(next.run(request).await);
}
let supplied = request_token(&request);
if supplied.as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
}
Ok(next.run(request).await)
}
async fn home_assistant_auth(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
let supplied = request_token(&request).ok_or(AppError::Unauthorized)?;
let admin_token = state.config.app_token.trim();
if !admin_token.is_empty() && supplied == admin_token {
return Ok(next.run(request).await);
}
if state.db.api_token_exists(&hash_token(&supplied))? {
return Ok(next.run(request).await);
}
Err(AppError::Unauthorized)
}
fn request_token(request: &Request) -> Option<String> {
request.headers().get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok()))
.map(str::to_owned)
}
fn hash_token(token: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes()))
}
fn generate_access_token() -> String {
let mut bytes = [0u8; 32];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes))
}
async fn health(State(state): State<AppState>) -> Json<Value> {
Json(json!({
"status": "ok",
"name": "gree-controller",
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
"time": Utc::now(),
}))
}
async fn bootstrap(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(build_bootstrap(&state).await?))
}
async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
let settings = state.settings.read().await.clone();
Ok(json!({
"devices": state.db.list_devices()?,
"zones": state.db.list_zones()?,
"schedules": state.db.list_schedules()?,
"automations": state.db.list_automations()?,
"access_tokens": state.db.list_api_tokens()?,
"settings": public_settings(&settings),
"system": {
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
"auth_required": !state.config.app_token.trim().is_empty(),
}
}))
}
async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let devices = state.db.list_devices()?;
Ok(Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
"database": state.config.database.display().to_string(),
"device_count": devices.len(),
"online_count": devices.iter().filter(|v| v.online).count(),
"simulator_count": devices.iter().filter(|v| v.simulated).count(),
"bind": state.config.bind.to_string(),
})))
}
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(300, 30_000);
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms)).await
.map_err(|e| AppError::Device(e.to_string()))?;
let mut saved = Vec::new();
for item in discovered {
let existing = state.db.get_device_by_mac(&item.mac)?;
let merged = merge_discovered(existing, item);
state.db.save_device(&merged)?;
saved.push(merged);
}
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len()}));
state.broadcast("devices.discovered", json!({"devices": saved}));
Ok(Json(json!({"count": saved.len(), "devices": saved})))
}
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
Ok(Json(state.db.list_devices()?))
}
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> {
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
return Err(AppError::BadRequest("name, mac and ip are required".into()));
}
input.ip.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
if state.db.get_device_by_mac(&input.mac)?.is_some() {
return Err(AppError::BadRequest("a device with this MAC already exists".into()));
}
let now = Utc::now();
let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase();
let device = Device {
id: format!("gree-{}", normalized_mac.to_ascii_lowercase()),
mac: normalized_mac,
name: input.name.trim().to_string(),
ip: input.ip,
port: input.port,
protocol_version: input.protocol_version.clamp(1, 2),
model: String::new(),
firmware: String::new(),
key: input.key.filter(|v| !v.trim().is_empty()),
cid: Some(state.settings.read().await.controller_id.clone()),
enabled: true,
simulated: input.simulated,
power: false,
mode: "cool".into(),
target_temperature: 24.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
quiet: false,
turbo: false,
light: true,
current_temperature: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
online: input.simulated,
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
created_at: now,
updated_at: now,
};
state.db.save_device(&device)?;
state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id}));
state.broadcast("device.created", serde_json::to_value(&device)?);
Ok((StatusCode::CREATED, Json(device)))
}
async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}")))
}
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
if let Some(v) = patch.port { device.port = v; }
if let Some(v) = patch.protocol_version { device.protocol_version = v.clamp(1, 2); }
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); }
if let Some(v) = patch.enabled { device.enabled = v; }
device.updated_at = Utc::now();
state.db.save_device(&device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?);
Ok(Json(device))
}
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
state.broadcast("device.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.simulated { return Ok(Json(device)); }
let key = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
device.key = Some(key);
device.online = true;
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
state.db.save_device(&device)?;
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id}));
Ok(Json(device))
}
async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::poll_one(&state, &id).await?))
}
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::send_command(&state, &id, command).await?))
}
#[derive(Debug, Deserialize)]
struct ZoneInput {
name: String,
device_id: String,
#[serde(default = "yes")]
enabled: bool,
#[serde(default = "cool")]
mode: String,
#[serde(default = "setpoint")]
setpoint: f64,
#[serde(default = "hysteresis")]
hysteresis: f64,
#[serde(default = "cycle")]
min_on_seconds: u64,
#[serde(default = "cycle")]
min_off_seconds: u64,
#[serde(default = "device_source")]
sensor_source: String,
#[serde(default)]
ha_entity_id: Option<String>,
#[serde(default = "external_sensor_weight")]
external_sensor_weight: f64,
#[serde(default = "max_sensor_difference")]
max_sensor_difference: f64,
}
fn yes() -> bool { true }
fn cool() -> String { "cool".into() }
fn setpoint() -> f64 { 24.0 }
fn hysteresis() -> f64 { 0.6 }
fn cycle() -> u64 { 180 }
fn external_sensor_weight() -> f64 { 0.4 }
fn max_sensor_difference() -> f64 { 3.0 }
fn device_source() -> String { "device".into() }
impl ZoneInput {
fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); }
if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 32 C".into())); }
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); }
if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); }
if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() {
return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into()));
}
Ok(())
}
fn into_zone(self, id: String, created_at: chrono::DateTime<Utc>) -> Zone {
Zone {
id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled,
mode: self.mode, setpoint: self.setpoint, hysteresis: self.hysteresis,
min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
demand: false, last_action_at: None,
created_at, updated_at: Utc::now(),
}
}
}
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> { Ok(Json(state.db.list_zones()?)) }
async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Zone>, AppError> {
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
}
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
input.validate()?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
let zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
state.db.save_zone(&zone)?;
state.broadcast("zone.created", serde_json::to_value(&zone)?);
Ok((StatusCode::CREATED, Json(zone)))
}
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
input.validate()?;
let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
let mut zone = input.into_zone(id, existing.created_at);
zone.device_temperature = existing.device_temperature;
zone.external_temperature = existing.external_temperature;
zone.current_temperature = existing.current_temperature;
zone.control_temperature_source = existing.control_temperature_source;
zone.demand = existing.demand;
zone.last_action_at = existing.last_action_at;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(Json(zone))
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
struct ScheduleInput {
zone_id: String,
name: String,
#[serde(default = "yes")]
enabled: bool,
weekdays: Vec<u32>,
start_time: String,
end_time: String,
setpoint: f64,
}
impl ScheduleInput {
fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 32 C".into())); }
Ok(())
}
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
setpoint: self.setpoint, created_at, updated_at: Utc::now() }
}
}
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
}
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
input.validate()?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
state.db.save_schedule(&item)?;
state.broadcast("schedule.created", serde_json::to_value(&item)?);
Ok((StatusCode::CREATED, Json(item)))
}
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
input.validate()?;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
let item = input.into_schedule(id, existing.created_at);
state.db.save_schedule(&item)?;
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
Ok(Json(item))
}
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
state.broadcast("schedule.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
struct AutomationInput {
name: String,
#[serde(default = "yes")]
enabled: bool,
trigger_kind: String,
#[serde(default)]
trigger_device_id: Option<String>,
#[serde(default)]
threshold: Option<f64>,
#[serde(default)]
at_time: Option<String>,
action_device_id: String,
#[serde(default)]
action: DeviceCommand,
#[serde(default = "automation_cooldown")]
cooldown_seconds: u64,
}
fn automation_cooldown() -> u64 { 300 }
impl AutomationInput {
fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); }
match self.trigger_kind.as_str() {
"temperature_above" | "temperature_below" => {
if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() {
return Err(AppError::BadRequest("temperature trigger needs device and threshold".into()));
}
}
"time" => {
let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
}
_ => return Err(AppError::BadRequest("unsupported automation trigger".into())),
}
Ok(())
}
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id,
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id,
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
created_at, updated_at: Utc::now() }
}
}
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
}
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
input.validate()?;
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); }
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
state.db.save_automation(&item)?;
state.broadcast("automation.created", serde_json::to_value(&item)?);
Ok((StatusCode::CREATED, Json(item)))
}
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
input.validate()?;
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); }
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
state.db.save_automation(&item)?;
state.broadcast("automation.updated", serde_json::to_value(&item)?);
Ok(Json(item))
}
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
state.broadcast("automation.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
struct ReadingsQuery { device_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQuery>) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?;
Ok(Json(json!({"readings": values})))
}
#[derive(Debug, Deserialize)]
struct EventsQuery { limit: Option<u32> }
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
}
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
let settings = state.settings.read().await;
Json(public_settings(&*settings))
}
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
let old = state.settings.read().await.clone();
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
input.discovery_broadcast.parse::<std::net::SocketAddr>()
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
if !input.home_assistant.url.trim().is_empty() {
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
}
state.db.save_runtime_settings(&input)?;
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
state.broadcast("settings.updated", public_settings(&input));
Ok(Json(public_settings(&input)))
}
#[derive(Debug, Deserialize)]
struct CreateAccessTokenRequest {
name: Option<String>,
}
async fn list_access_tokens(State(state): State<AppState>) -> Result<Json<Vec<ApiTokenInfo>>, AppError> {
Ok(Json(state.db.list_api_tokens()?))
}
async fn create_access_token(
State(state): State<AppState>,
Json(input): Json<CreateAccessTokenRequest>,
) -> Result<(StatusCode, Json<Value>), AppError> {
let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string();
if name.is_empty() || name.len() > 80 {
return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into()));
}
let secret = generate_access_token();
let item = ApiTokenInfo {
id: Uuid::new_v4().to_string(),
name,
token_prefix: format!("{}...", secret.chars().take(24).collect::<String>()),
created_at: Utc::now(),
};
state.db.save_api_token(&item, &hash_token(&secret))?;
state.log(
"info",
"access_token.created",
"Created a Home Assistant access token",
json!({"token_id": item.id.clone(), "name": item.name.clone()}),
);
Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item}))))
}
async fn delete_access_token(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_api_token(&id)? {
return Err(AppError::NotFound(format!("access token {id}")));
}
state.log(
"info",
"access_token.revoked",
"Revoked a Home Assistant access token",
json!({"token_id": id}),
);
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
struct HaTestRequest { entity_id: Option<String> }
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, input.entity_id.as_deref())
.await.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(json!({"ok": true, "temperature_c": temperature})))
}
fn public_settings(settings: &RuntimeSettings) -> Value {
json!({
"controller_id": settings.controller_id,
"simulator_enabled": settings.simulator_enabled,
"poll_interval_seconds": settings.poll_interval_seconds,
"zone_interval_seconds": settings.zone_interval_seconds,
"discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast,
"home_assistant": {
"url": settings.home_assistant.url,
"token": "",
"token_configured": !settings.home_assistant.token.trim().is_empty(),
"default_entity_id": settings.home_assistant.default_entity_id,
}
})
}
#[derive(Debug, Deserialize)]
struct WsQuery { token: Option<String> }
async fn websocket(State(state): State<AppState>, Query(query): Query<WsQuery>, ws: WebSocketUpgrade) -> Result<Response, AppError> {
let expected = state.config.app_token.trim();
if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); }
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
}
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
let initial = match build_bootstrap(&state).await {
Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}),
};
if socket.send(Message::Text(initial.to_string())).await.is_err() { return; }
let mut receiver = state.events.subscribe();
loop {
tokio::select! {
event = receiver.recv() => {
match event {
Ok(event) => {
if let Ok(text) = serde_json::to_string(&event) {
if socket.send(Message::Text(text)).await.is_err() { break; }
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
message = socket.next() => {
match message {
Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } }
Some(Ok(Message::Text(text))) if text == "ping" => { if socket.send(Message::Text("pong".into())).await.is_err() { break; } }
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
_ => {}
}
}
}
}
}
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") }
async fn language_index() -> Response {
static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache")
}
async fn language_file(Path(file): Path<String>) -> Response {
let code = file.strip_suffix(".json").unwrap_or(&file);
if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) {
return static_response(*body, "application/json; charset=utf-8", "no-cache");
}
let mut response = Response::new(Body::from("Language not found"));
*response.status_mut() = StatusCode::NOT_FOUND;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
response
}
fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response {
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
response
}
+57
View File
@@ -0,0 +1,57 @@
use std::{env, net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use crate::models::{HomeAssistantSettings, RuntimeSettings};
#[derive(Debug, Clone, Parser)]
#[command(author, version, about)]
pub struct Config {
#[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")]
pub bind: SocketAddr,
#[arg(long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db")]
pub database: PathBuf,
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
pub app_token: String,
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = true)]
pub simulate: bool,
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = true)]
pub auto_seed: bool,
#[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)]
pub poll_interval_seconds: u64,
#[arg(long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5)]
pub zone_interval_seconds: u64,
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000)]
pub discovery_timeout_ms: u64,
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")]
pub discovery_broadcast: String,
#[arg(long, env = "GREE_CONTROLLER_ID", default_value = "gree-controller")]
pub controller_id: String,
}
impl Config {
pub fn load() -> Result<Self> {
dotenvy::dotenv().ok();
let config = Self::parse();
if let Some(parent) = config.database.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
}
Ok(config)
}
pub fn runtime_defaults(&self) -> RuntimeSettings {
RuntimeSettings {
controller_id: self.controller_id.clone(),
simulator_enabled: self.simulate,
poll_interval_seconds: self.poll_interval_seconds.max(2),
zone_interval_seconds: self.zone_interval_seconds.max(2),
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
discovery_broadcast: self.discovery_broadcast.clone(),
home_assistant: HomeAssistantSettings {
url: env::var("HA_URL").unwrap_or_default(),
token: env::var("HA_TOKEN").unwrap_or_default(),
default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(),
},
}
}
}
+341
View File
@@ -0,0 +1,341 @@
use std::{path::Path, sync::{Arc, Mutex}};
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::{
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone},
queries,
};
#[derive(Clone)]
pub struct Db {
conn: Arc<Mutex<Connection>>,
}
impl Db {
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)
.with_context(|| format!("cannot open SQLite database {}", path.display()))?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
conn.execute_batch(queries::INIT_SCHEMA)?;
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned"))
}
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
Ok(serde_json::from_str(&payload)?)
}
fn to_json<T: Serialize>(value: &T) -> Result<String> {
Ok(serde_json::to_string(value)?)
}
pub fn count_devices(&self) -> Result<u64> {
let conn = self.lock()?;
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
Ok(count.max(0) as u64)
}
pub fn save_device(&self, device: &Device) -> Result<()> {
let payload = Self::to_json(device)?;
let conn = self.lock()?;
conn.execute(
queries::UPSERT_DEVICE,
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()],
)?;
Ok(())
}
pub fn list_devices(&self) -> Result<Vec<Device>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
payloads.into_iter().map(Self::from_json).collect()
}
pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
let conn = self.lock()?;
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?;
payload.map(Self::from_json).transpose()
}
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
let conn = self.lock()?;
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?;
payload.map(Self::from_json).transpose()
}
pub fn delete_device(&self, id: &str) -> Result<bool> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
tx.commit()?;
Ok(changed)
}
pub fn save_zone(&self, zone: &Zone) -> Result<()> {
let payload = Self::to_json(zone)?;
let conn = self.lock()?;
conn.execute(
queries::UPSERT_ZONE,
params![zone.id, payload, zone.updated_at.to_rfc3339()],
)?;
Ok(())
}
pub fn list_zones(&self) -> Result<Vec<Zone>> {
self.list_payloads(queries::LIST_ZONES)
}
pub fn get_zone(&self, id: &str) -> Result<Option<Zone>> {
self.get_payload(queries::GET_ZONE, id)
}
pub fn delete_zone(&self, id: &str) -> Result<bool> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?;
let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0;
tx.commit()?;
Ok(changed)
}
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
let payload = Self::to_json(schedule)?;
let conn = self.lock()?;
conn.execute(
queries::UPSERT_SCHEDULE,
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
)?;
Ok(())
}
pub fn list_schedules(&self) -> Result<Vec<Schedule>> {
self.list_payloads(queries::LIST_SCHEDULES)
}
pub fn get_schedule(&self, id: &str) -> Result<Option<Schedule>> {
self.get_payload(queries::GET_SCHEDULE, id)
}
pub fn delete_schedule(&self, id: &str) -> Result<bool> {
self.delete_by_id("schedules", id)
}
pub fn save_automation(&self, item: &Automation) -> Result<()> {
let payload = Self::to_json(item)?;
let conn = self.lock()?;
conn.execute(
queries::UPSERT_AUTOMATION,
params![item.id, payload, item.updated_at.to_rfc3339()],
)?;
Ok(())
}
pub fn list_automations(&self) -> Result<Vec<Automation>> {
self.list_payloads(queries::LIST_AUTOMATIONS)
}
pub fn get_automation(&self, id: &str) -> Result<Option<Automation>> {
self.get_payload(queries::GET_AUTOMATION, id)
}
pub fn delete_automation(&self, id: &str) -> Result<bool> {
self.delete_by_id("automations", id)
}
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(sql)?;
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
payloads.into_iter().map(Self::from_json).collect()
}
fn get_payload<T: DeserializeOwned>(&self, sql: &str, id: &str) -> Result<Option<T>> {
let conn = self.lock()?;
let payload: Option<String> = conn.query_row(sql, [id], |row| row.get(0)).optional()?;
payload.map(Self::from_json).transpose()
}
fn delete_by_id(&self, table: &str, id: &str) -> Result<bool> {
let sql = match table {
"schedules" => queries::DELETE_SCHEDULE,
"automations" => queries::DELETE_AUTOMATION,
_ => anyhow::bail!("unsupported table"),
};
let conn = self.lock()?;
Ok(conn.execute(sql, [id])? > 0)
}
pub fn add_reading(&self, reading: &Reading) -> Result<i64> {
let conn = self.lock()?;
conn.execute(
queries::INSERT_READING,
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature,
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source],
)?;
Ok(conn.last_insert_rowid())
}
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> {
let conn = self.lock()?;
let limit = limit.clamp(1, 5000) as i64;
let mut rows_out = Vec::new();
if let Some(device_id) = device_id {
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?;
for row in rows { rows_out.push(row?); }
} else {
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
for row in rows { rows_out.push(row?); }
}
Ok(rows_out)
}
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
let timestamp: String = row.get(2)?;
Ok(Reading {
id: row.get(0)?,
device_id: row.get(1)?,
timestamp: DateTime::parse_from_rfc3339(&timestamp)
.map(|v| v.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
indoor_temperature: row.get(3)?,
outdoor_temperature: row.get(4)?,
target_temperature: row.get(5)?,
power: row.get::<_, i64>(6)? != 0,
source: row.get(7)?,
})
}
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?;
Ok(conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64)
}
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
let conn = self.lock()?;
conn.execute(
queries::INSERT_EVENT,
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?],
)?;
Ok(conn.last_insert_rowid())
}
pub fn list_events(&self, limit: u32) -> Result<Vec<EventLog>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(queries::LIST_EVENTS)?;
let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| {
let ts: String = row.get(1)?;
let metadata: String = row.get(5)?;
Ok(EventLog {
id: row.get(0)?,
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()),
level: row.get(2)?,
kind: row.get(3)?,
message: row.get(4)?,
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
let rows = stmt.query_map([], |row| {
let created_at: String = row.get(3)?;
Ok(ApiTokenInfo {
id: row.get(0)?,
name: row.get(1)?,
token_prefix: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&created_at)
.map(|value| value.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
let conn = self.lock()?;
conn.execute(
queries::INSERT_API_TOKEN,
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()],
)?;
Ok(())
}
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
let conn = self.lock()?;
let found: Option<i64> = conn.query_row(
queries::API_TOKEN_EXISTS,
[token_hash],
|row| row.get(0),
).optional()?;
Ok(found.is_some())
}
pub fn delete_api_token(&self, id: &str) -> Result<bool> {
let conn = self.lock()?;
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
}
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
let conn = self.lock()?;
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?;
value.map(Self::from_json).transpose()
}
pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> {
let value = Self::to_json(settings)?;
let conn = self.lock()?;
conn.execute(
queries::UPSERT_RUNTIME_SETTINGS,
params![value, Utc::now().to_rfc3339()],
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ApiTokenInfo, Device};
#[test]
fn sqlite_round_trip() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("test.db")).unwrap();
let device = Device::simulated_default();
db.save_device(&device).unwrap();
let loaded = db.get_device(&device.id).unwrap().unwrap();
assert_eq!(loaded.mac, device.mac);
assert_eq!(db.list_devices().unwrap().len(), 1);
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
assert_eq!(db.list_events(10).unwrap().len(), 1);
let access_token = ApiTokenInfo {
id: "token-1".into(),
name: "Home Assistant".into(),
token_prefix: "gree_controller_test...".into(),
created_at: Utc::now(),
};
db.save_api_token(&access_token, "test-hash").unwrap();
assert!(db.api_token_exists("test-hash").unwrap());
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
assert!(db.delete_api_token(&access_token.id).unwrap());
assert!(!db.api_token_exists("test-hash").unwrap());
}
}
+450
View File
@@ -0,0 +1,450 @@
use std::time::Duration;
use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::json;
use tokio::time::sleep;
use crate::{
error::AppError,
home_assistant,
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone},
state::AppState,
};
pub fn start(state: AppState) {
let poll_state = state.clone();
tokio::spawn(async move {
sleep(Duration::from_millis(500)).await;
loop {
if let Err(err) = poll_all(&poll_state).await {
tracing::error!(error=?err, "device poll cycle failed");
}
let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2);
sleep(Duration::from_secs(seconds)).await;
}
});
let control_state = state.clone();
tokio::spawn(async move {
sleep(Duration::from_secs(2)).await;
loop {
if let Err(err) = control_zones(&control_state).await {
tracing::error!(error=?err, "zone cycle failed");
}
if let Err(err) = run_automations(&control_state).await {
tracing::error!(error=?err, "automation cycle failed");
}
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
sleep(Duration::from_secs(seconds)).await;
}
});
let maintenance_state = state;
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(6 * 60 * 60)).await;
match maintenance_state.db.prune_readings(30) {
Ok(count) if count > 0 => tracing::info!(count, "old readings pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
}
}
});
}
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
validate_command(&command)?;
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
if device.simulated {
command.apply(&mut device);
device.online = true;
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
} else {
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(&device).await {
Ok(key) => {
device.key = Some(key);
state.db.save_device(&device)?;
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": device.id}));
}
Err(err) => {
mark_device_error(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
}
}
}
if let Err(err) = state.gree.command(&device, &command).await {
mark_device_error(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
}
command.apply(&mut device);
device.online = true;
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
}
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
"device_id": device.id,
"command": command,
}));
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
Ok(device)
}
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
poll_device(state, &mut device).await;
state.db.save_device(&device)?;
record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
Ok(device)
}
async fn poll_all(state: &AppState) -> Result<()> {
for mut device in state.db.list_devices()? {
if !device.enabled { continue; }
poll_device(state, &mut device).await;
state.db.save_device(&device)?;
record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?);
}
Ok(())
}
async fn poll_device(state: &AppState, device: &mut Device) {
if device.simulated {
simulate_tick(device);
return;
}
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(key) => device.key = Some(key),
Err(err) => {
device.online = false;
device.last_error = Some(err.to_string());
device.updated_at = Utc::now();
return;
}
}
}
if let Err(err) = state.gree.poll(device).await {
device.online = false;
device.last_error = Some(err.to_string());
device.updated_at = Utc::now();
}
}
fn simulate_tick(device: &mut Device) {
let mut current = device.current_temperature.unwrap_or(25.0);
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
let ambient = 25.5 + minute_wave * 0.35;
if device.power {
match device.mode.as_str() {
"cool" => {
let floor = device.target_temperature - 0.2;
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
}
"heat" => {
let ceiling = device.target_temperature + 0.2;
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
}
"dry" => current -= 0.04,
_ => current += (ambient - current) * 0.02,
}
} else {
current += (ambient - current) * 0.04;
}
device.current_temperature = Some((current * 10.0).round() / 10.0);
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
// Correct rounding for outdoor temperature without accumulating precision noise.
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
device.online = true;
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
}
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
state.db.add_reading(&Reading {
id: 0,
device_id: device.id.clone(),
timestamp: Utc::now(),
indoor_temperature: device.current_temperature,
outdoor_temperature: device.outdoor_temperature,
target_temperature: device.target_temperature,
power: device.power,
source: if device.simulated { "simulator".into() } else { "gree".into() },
})?;
Ok(())
}
fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
device.online = false;
device.last_error = Some(error.to_string());
device.updated_at = Utc::now();
state.db.save_device(device)?;
state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id}));
Ok(())
}
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
if let Some(value) = command.target_temperature {
if !(8.0..=32.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 32 C".into())); }
}
if let Some(value) = command.fan_speed {
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
}
if let Some(value) = &command.mode {
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
}
}
Ok(())
}
async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
for mut zone in state.db.list_zones()? {
if !zone.enabled { continue; }
if let Some(setpoint) = active_setpoint(&zone, &schedules, Local::now()) {
zone.setpoint = setpoint;
}
let Some(device) = state.db.get_device(&zone.device_id)? else {
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
continue;
};
let previous_source = zone.control_temperature_source.clone();
let device_temperature = device.current_temperature;
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
Ok(value) => Some(value),
Err(err) => {
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
}
None
}
}
} else {
None
};
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
zone.device_temperature = device_temperature;
zone.external_temperature = external_temperature;
zone.current_temperature = temperature;
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
if discrepancy && previous_source != "device_discrepancy_fallback" {
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
"zone_id": zone.id,
"device_temperature": zone.device_temperature,
"external_temperature": zone.external_temperature,
"max_difference": zone.max_sensor_difference,
"entity_id": zone.ha_entity_id.as_deref(),
}));
}
let Some(temp) = temperature else { state.db.save_zone(&zone)?; continue; };
let half = zone.hysteresis.max(0.1) / 2.0;
let desired = match zone.mode.as_str() {
"heat" => if temp <= zone.setpoint - half { Some(true) } else if temp >= zone.setpoint + half { Some(false) } else { None },
_ => if temp >= zone.setpoint + half { Some(true) } else if temp <= zone.setpoint - half { Some(false) } else { None },
};
if let Some(on) = desired {
zone.demand = on;
if on != device.power && cycle_allowed(&zone, device.power) {
let command = DeviceCommand {
power: Some(on),
mode: if on { Some(zone.mode.clone()) } else { None },
target_temperature: if on { Some(zone.setpoint) } else { None },
..Default::default()
};
match send_command(state, &zone.device_id, command).await {
Ok(_) => {
zone.last_action_at = Some(Utc::now());
state.log("info", "zone.action", &format!("Zone {} demand {}", zone.name, if on { "ON" } else { "OFF" }), json!({
"zone_id": zone.id, "temperature": temp, "setpoint": zone.setpoint,
}));
}
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
}
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) {
(Some(value), _) => (Some(value), "external".into(), false),
(None, Some(value)) => (Some(value), "device_fallback".into(), false),
(None, None) => (None, "unavailable".into(), false),
},
"combined" => match (device_temperature, external_temperature) {
(Some(device), Some(external)) => {
if (device - external).abs() > zone.max_sensor_difference.max(0.1) {
(Some(device), "device_discrepancy_fallback".into(), true)
} else {
let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0);
let value = device * (1.0 - external_weight) + external * external_weight;
(Some((value * 10.0).round() / 10.0), "combined".into(), false)
}
}
(Some(value), None) => (Some(value), "device_fallback".into(), false),
(None, Some(value)) => (Some(value), "external".into(), false),
(None, None) => (None, "unavailable".into(), false),
},
_ => match device_temperature {
Some(value) => (Some(value), "device".into(), false),
None => (None, "unavailable".into(), false),
},
}
}
fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool {
let Some(last) = zone.last_action_at else { return true; };
let elapsed = (Utc::now() - last).num_seconds().max(0) as u64;
if currently_on { elapsed >= zone.min_on_seconds } else { elapsed >= zone.min_off_seconds }
}
fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime<Local>) -> Option<f64> {
schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
.last()
.map(|item| item.setpoint)
}
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; };
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
let time = now.time();
let today = now.weekday().number_from_monday();
if start <= end {
item.weekdays.contains(&today) && time >= start && time < end
} else if time >= start {
item.weekdays.contains(&today)
} else if time < end {
let previous = previous_weekday(now.weekday()).number_from_monday();
item.weekdays.contains(&previous)
} else {
false
}
}
fn previous_weekday(day: Weekday) -> Weekday {
match day {
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri,
Weekday::Sun => Weekday::Sat,
}
}
async fn run_automations(state: &AppState) -> Result<()> {
let devices = state.db.list_devices()?;
for mut item in state.db.list_automations()? {
if !item.enabled || !automation_ready(&item) { continue; }
let should_fire = match item.trigger_kind.as_str() {
"temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref())
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
"time" => item.at_time.as_deref().map(time_matches).unwrap_or(false),
_ => false,
};
if !should_fire { continue; }
match send_command(state, &item.action_device_id, item.action.clone()).await {
Ok(_) => {
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({"automation_id": item.id}));
}
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
}
}
Ok(())
}
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
let id = device_id?;
devices.iter().find(|d| d.id == id)?.current_temperature
}
fn automation_ready(item: &Automation) -> bool {
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
}
fn time_matches(expected: &str) -> bool {
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
let now = Local::now().time();
now.hour() == value.hour() && now.minute() == value.minute()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn overnight_schedule_works() {
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
let item = Schedule {
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), setpoint: 20.0,
created_at: Utc::now(), updated_at: Utc::now(),
};
assert!(schedule_active(&item, now));
}
fn test_zone(source: &str) -> Zone {
Zone {
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
mode: "heat".into(), setpoint: 21.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180,
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), demand: false, last_action_at: None,
created_at: Utc::now(), updated_at: Utc::now(),
}
}
#[test]
fn combined_temperature_prefers_room_sensor_weight() {
let zone = test_zone("combined");
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0));
assert_eq!(value, Some(21.2));
assert_eq!(source, "combined");
assert!(!discrepancy);
}
#[test]
fn combined_temperature_falls_back_on_large_discrepancy() {
let zone = test_zone("combined");
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0));
assert_eq!(value, Some(21.0));
assert_eq!(source, "device_discrepancy_fallback");
assert!(discrepancy);
}
#[test]
fn combined_temperature_falls_back_when_external_is_missing() {
let zone = test_zone("combined");
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None);
assert_eq!(value, Some(21.5));
assert_eq!(source, "device_fallback");
assert!(!discrepancy);
}
}
+41
View File
@@ -0,0 +1,41 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("invalid request: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("device communication failed: {0}")]
Device(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(v) => (StatusCode::NOT_FOUND, v.clone()),
Self::BadRequest(v) => (StatusCode::BAD_REQUEST, v.clone()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()),
Self::Internal(v) => {
tracing::error!(error = ?v, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into())
}
};
(status, Json(json!({"error": message}))).into_response()
}
}
impl From<rusqlite::Error> for AppError {
fn from(value: rusqlite::Error) -> Self { Self::Internal(value.into()) }
}
impl From<serde_json::Error> for AppError {
fn from(value: serde_json::Error) -> Self { Self::Internal(value.into()) }
}
+40
View File
@@ -0,0 +1,40 @@
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value;
use url::Url;
use crate::models::HomeAssistantSettings;
pub async fn read_temperature(
client: &reqwest::Client,
settings: &HomeAssistantSettings,
entity_override: Option<&str>,
) -> Result<f64> {
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") }
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") }
let entity = entity_override.filter(|v| !v.trim().is_empty())
.unwrap_or(settings.default_entity_id.trim());
if entity.is_empty() { bail!("Home Assistant entity_id is not configured") }
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") }
let path = format!("api/states/{}", entity.trim_start_matches('/'));
base = base.join(&path).context("cannot build Home Assistant API URL")?;
let response = client.get(base)
.bearer_auth(settings.token.trim())
.header("Accept", "application/json")
.send().await.context("Home Assistant request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>())
}
let payload: Value = response.json().await.context("invalid Home Assistant JSON")?;
let state = payload.get("state").and_then(Value::as_str)
.ok_or_else(|| anyhow!("Home Assistant state is missing"))?;
let mut temperature: f64 = state.parse().context("Home Assistant state is not a number")?;
let unit = payload.pointer("/attributes/unit_of_measurement").and_then(Value::as_str).unwrap_or("°C");
if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") {
temperature = (temperature - 32.0) * 5.0 / 9.0;
}
Ok((temperature * 10.0).round() / 10.0)
}
+96
View File
@@ -0,0 +1,96 @@
mod api;
mod config;
mod db;
mod engine;
mod error;
mod home_assistant;
mod models;
mod protocol;
mod queries;
mod state;
use std::{sync::Arc, time::{Duration, Instant}};
use anyhow::{Context, Result};
use config::Config;
use db::Db;
use models::Device;
use protocol::GreeClient;
use state::AppState;
use tokio::{net::TcpListener, signal, sync::{broadcast, RwLock}};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::load()?;
init_tracing();
let db = Db::open(&config.database)?;
let runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults());
db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
db.save_device(&Device::simulated_default())?;
db.log_event(
"info",
"simulator.seeded",
"Created the default simulator device",
&serde_json::json!({"device_id":"sim-salon"}),
)?;
}
let (events, _) = broadcast::channel(256);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
.build()?;
let state = AppState {
db,
settings: Arc::new(RwLock::new(runtime_settings.clone())),
config: Arc::new(config.clone()),
gree: GreeClient::new(runtime_settings.controller_id.clone()),
events,
http,
started: Instant::now(),
};
engine::start(state.clone());
let app = api::router(state.clone());
let listener = TcpListener::bind(config.bind).await
.with_context(|| format!("cannot bind HTTP server to {}", config.bind))?;
tracing::info!(
address = %config.bind,
database = %config.database.display(),
simulator = config.simulate,
auth = !config.app_token.trim().is_empty(),
"GREE Controller started"
);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("GREE Controller stopped");
Ok(())
}
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,tower_http=info".into());
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().compact())
.init();
}
async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.expect("cannot install Ctrl+C handler"); };
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("cannot install SIGTERM handler")
.recv().await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
}
+317
View File
@@ -0,0 +1,317 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn default_true() -> bool { true }
fn default_port() -> u16 { 7000 }
fn default_protocol() -> u8 { 1 }
fn default_mode() -> String { "cool".into() }
fn default_fan() -> u8 { 0 }
fn default_target() -> f64 { 24.0 }
fn default_hysteresis() -> f64 { 0.6 }
fn default_external_sensor_weight() -> f64 { 0.4 }
fn default_max_sensor_difference() -> f64 { 3.0 }
fn default_control_temperature_source() -> String { "device".into() }
fn default_min_cycle() -> u64 { 180 }
fn default_cooldown() -> u64 { 300 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: String,
pub mac: String,
pub name: String,
pub ip: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_protocol")]
pub protocol_version: u8,
#[serde(default)]
pub model: String,
#[serde(default)]
pub firmware: String,
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub cid: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub simulated: bool,
#[serde(default)]
pub power: bool,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_target")]
pub target_temperature: f64,
#[serde(default = "default_fan")]
pub fan_speed: u8,
#[serde(default)]
pub swing_vertical: bool,
#[serde(default)]
pub swing_horizontal: bool,
#[serde(default)]
pub quiet: bool,
#[serde(default)]
pub turbo: bool,
#[serde(default)]
pub light: bool,
#[serde(default)]
pub current_temperature: Option<f64>,
#[serde(default)]
pub outdoor_temperature: Option<f64>,
#[serde(default)]
pub online: bool,
#[serde(default)]
pub last_seen: Option<DateTime<Utc>>,
#[serde(default)]
pub last_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Device {
pub fn simulated_default() -> Self {
let now = Utc::now();
Self {
id: "sim-salon".into(),
mac: "SIM000000001".into(),
name: "Living Room (simulator)".into(),
ip: "127.0.0.1".into(),
port: 7000,
protocol_version: 1,
model: "GREE-SIM".into(),
firmware: "sim-1.0".into(),
key: None,
cid: Some("gree-controller".into()),
enabled: true,
simulated: true,
power: false,
mode: "cool".into(),
target_temperature: 23.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
quiet: false,
turbo: false,
light: true,
current_temperature: Some(26.0),
outdoor_temperature: Some(30.0),
online: true,
last_seen: Some(now),
last_error: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DevicePatch {
pub name: Option<String>,
pub ip: Option<String>,
pub port: Option<u16>,
pub protocol_version: Option<u8>,
pub key: Option<Option<String>>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceCommand {
pub power: Option<bool>,
pub mode: Option<String>,
pub target_temperature: Option<f64>,
pub fan_speed: Option<u8>,
pub swing_vertical: Option<bool>,
pub swing_horizontal: Option<bool>,
pub quiet: Option<bool>,
pub turbo: Option<bool>,
pub light: Option<bool>,
}
impl DeviceCommand {
pub fn apply(&self, device: &mut Device) {
if let Some(v) = self.power { device.power = v; }
if let Some(v) = &self.mode { device.mode = v.clone(); }
if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 32.0); }
if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); }
if let Some(v) = self.swing_vertical { device.swing_vertical = v; }
if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; }
if let Some(v) = self.quiet { device.quiet = v; }
if let Some(v) = self.turbo { device.turbo = v; }
if let Some(v) = self.light { device.light = v; }
device.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Zone {
pub id: String,
pub name: String,
pub device_id: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_target")]
pub setpoint: f64,
#[serde(default = "default_hysteresis")]
pub hysteresis: f64,
#[serde(default = "default_min_cycle")]
pub min_on_seconds: u64,
#[serde(default = "default_min_cycle")]
pub min_off_seconds: u64,
#[serde(default = "default_sensor_source")]
pub sensor_source: String,
#[serde(default)]
pub ha_entity_id: Option<String>,
/// Weight of the optional room sensor when sensor_source is `combined`.
#[serde(default = "default_external_sensor_weight")]
pub external_sensor_weight: f64,
/// If GREE and external sensor differ more than this, the controller falls back to GREE.
#[serde(default = "default_max_sensor_difference")]
pub max_sensor_difference: f64,
/// Temperature reported by the GREE indoor sensor during the last zone cycle.
#[serde(default)]
pub device_temperature: Option<f64>,
/// Temperature reported by the per-zone external Home Assistant sensor.
#[serde(default)]
pub external_temperature: Option<f64>,
/// Temperature actually used by the zone controller.
#[serde(default)]
pub current_temperature: Option<f64>,
/// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`.
#[serde(default = "default_control_temperature_source")]
pub control_temperature_source: String,
#[serde(default)]
pub demand: bool,
#[serde(default)]
pub last_action_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_sensor_source() -> String { "device".into() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schedule {
pub id: String,
pub zone_id: String,
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
/// ISO weekday numbers, Monday=1, Sunday=7.
pub weekdays: Vec<u32>,
/// Local time HH:MM.
pub start_time: String,
/// Local time HH:MM. Ranges crossing midnight are supported.
pub end_time: String,
pub setpoint: f64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Automation {
pub id: String,
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
/// temperature_above, temperature_below, time
pub trigger_kind: String,
#[serde(default)]
pub trigger_device_id: Option<String>,
#[serde(default)]
pub threshold: Option<f64>,
#[serde(default)]
pub at_time: Option<String>,
pub action_device_id: String,
#[serde(default)]
pub action: DeviceCommand,
#[serde(default = "default_cooldown")]
pub cooldown_seconds: u64,
#[serde(default)]
pub last_fired_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reading {
pub id: i64,
pub device_id: String,
pub timestamp: DateTime<Utc>,
pub indoor_temperature: Option<f64>,
pub outdoor_temperature: Option<f64>,
pub target_temperature: f64,
pub power: bool,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLog {
pub id: i64,
pub timestamp: DateTime<Utc>,
pub level: String,
pub kind: String,
pub message: String,
pub metadata: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HomeAssistantSettings {
#[serde(default)]
pub url: String,
#[serde(default)]
pub token: String,
#[serde(default)]
pub default_entity_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSettings {
pub controller_id: String,
pub simulator_enabled: bool,
pub poll_interval_seconds: u64,
pub zone_interval_seconds: u64,
pub discovery_timeout_ms: u64,
pub discovery_broadcast: String,
pub home_assistant: HomeAssistantSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryRequest {
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub broadcast: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManualDeviceRequest {
pub name: String,
pub mac: String,
pub ip: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_protocol")]
pub protocol_version: u8,
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub simulated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiTokenInfo {
pub id: String,
pub name: String,
pub token_prefix: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiEvent {
pub event: String,
pub timestamp: DateTime<Utc>,
pub data: Value,
}
+112
View File
@@ -0,0 +1,112 @@
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
use anyhow::{anyhow, bail, Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use rand::RngCore;
pub const GENERIC_GREE_KEY: &str = "a3K8Bx%2r8Y7cB!a";
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
let bytes = key.as_bytes();
if bytes.len() == 16 {
let mut out = [0_u8; 16];
out.copy_from_slice(bytes);
return Ok(out);
}
if let Ok(decoded) = STANDARD.decode(key) {
if decoded.len() == 16 {
let mut out = [0_u8; 16];
out.copy_from_slice(&decoded);
return Ok(out);
}
}
bail!("GREE key must contain 16 bytes or base64-encoded 16 bytes")
}
pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
let key = normalize_key(key)?;
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
let pad = 16 - (plaintext.len() % 16);
let mut data = Vec::with_capacity(plaintext.len() + pad);
data.extend_from_slice(plaintext);
data.extend(std::iter::repeat(pad as u8).take(pad));
for block in data.chunks_exact_mut(16) {
cipher.encrypt_block(GenericArray::from_mut_slice(block));
}
Ok(STANDARD.encode(data))
}
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
let key = normalize_key(key)?;
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
let mut data = STANDARD.decode(ciphertext_b64).context("invalid base64 packet")?;
if data.is_empty() || data.len() % 16 != 0 {
bail!("invalid AES-ECB ciphertext length")
}
for block in data.chunks_exact_mut(16) {
cipher.decrypt_block(GenericArray::from_mut_slice(block));
}
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
if pad == 0 || pad > 16 || data.len() < pad || data[data.len() - pad..].iter().any(|v| *v as usize != pad) {
bail!("invalid PKCS#7 padding")
}
data.truncate(data.len() - pad);
Ok(data)
}
#[derive(Debug, Clone)]
pub struct V2Encrypted {
pub ciphertext: String,
pub nonce: String,
pub tag: String,
}
pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
let key = normalize_key(key)?;
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
let mut nonce_bytes = [0_u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = plaintext.to_vec();
let tag = cipher.encrypt_in_place_detached(nonce, b"", &mut buffer)
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
Ok(V2Encrypted {
ciphertext: STANDARD.encode(buffer),
nonce: STANDARD.encode(nonce_bytes),
tag: STANDARD.encode(tag),
})
}
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, nonce_b64: &str, tag_b64: &str) -> Result<Vec<u8>> {
let key = normalize_key(key)?;
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
let nonce_bytes = STANDARD.decode(nonce_b64).context("invalid GCM nonce")?;
if nonce_bytes.len() != 12 { bail!("invalid GCM nonce length") }
let tag_bytes = STANDARD.decode(tag_b64).context("invalid GCM tag")?;
if tag_bytes.len() != 16 { bail!("invalid GCM tag length") }
let mut data = STANDARD.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
let nonce = Nonce::from_slice(&nonce_bytes);
let tag = GenericArray::from_slice(&tag_bytes);
cipher.decrypt_in_place_detached(nonce, b"", &mut data, tag)
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
Ok(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v1_round_trip() {
let value = br#"{"t":"status","mac":"112233445566"}"#;
let encrypted = encrypt_v1(GENERIC_GREE_KEY, value).unwrap();
assert_eq!(decrypt_v1(GENERIC_GREE_KEY, &encrypted).unwrap(), value);
}
#[test]
fn v2_round_trip() {
let value = b"gree-gcm-test";
let encrypted = encrypt_v2(GENERIC_GREE_KEY, value).unwrap();
assert_eq!(decrypt_v2(GENERIC_GREE_KEY, &encrypted.ciphertext, &encrypted.nonce, &encrypted.tag).unwrap(), value);
}
}
+259
View File
@@ -0,0 +1,259 @@
use std::{collections::HashSet, net::SocketAddr, sync::{Arc, atomic::{AtomicU64, Ordering}}, time::Duration};
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use serde_json::{json, Value};
use tokio::{net::UdpSocket, time::{timeout, Instant}};
use uuid::Uuid;
use crate::models::{Device, DeviceCommand};
use super::crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_KEY};
#[derive(Clone)]
pub struct GreeClient {
controller_id: String,
sequence: Arc<AtomicU64>,
}
impl GreeClient {
pub fn new(controller_id: String) -> Self {
Self { controller_id, sequence: Arc::new(AtomicU64::new(1)) }
}
fn next_id(&self) -> u64 { self.sequence.fetch_add(1, Ordering::Relaxed) }
pub async fn discover(&self, broadcast: &str, duration: Duration) -> Result<Vec<Device>> {
let target: SocketAddr = broadcast.parse().context("invalid discovery broadcast address")?;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
socket.send_to(br#"{"t":"scan"}"#, target).await?;
let deadline = Instant::now() + duration;
let mut result = Vec::new();
let mut seen = HashSet::new();
let mut buffer = vec![0_u8; 8192];
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
match timeout(remaining.min(Duration::from_millis(450)), socket.recv_from(&mut buffer)).await {
Ok(Ok((size, source))) => {
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) {
if let Some(mut device) = self.parse_discovery(value, source) {
let key = device.mac.to_ascii_lowercase();
if seen.insert(key) {
device.last_seen = Some(Utc::now());
result.push(device);
}
}
}
}
Ok(Err(err)) => return Err(err.into()),
Err(_) => continue,
}
}
Ok(result)
}
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Option<Device> {
if value.get("t").and_then(Value::as_str) == Some("pack") {
if let Some(pack) = value.get("pack").and_then(Value::as_str) {
if let Ok(clear) = decrypt_v1(GENERIC_GREE_KEY, pack) {
if let Ok(inner) = serde_json::from_slice::<Value>(&clear) { value = inner; }
}
}
}
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default();
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() {
return None;
}
let mac = value.get("mac").or_else(|| value.get("cid"))?.as_str()?.replace(':', "");
if mac.is_empty() { return None; }
let name = value.get("name").and_then(Value::as_str)
.filter(|v| !v.trim().is_empty())
.unwrap_or("Klimatyzator GREE").to_string();
let model = value.get("model").or_else(|| value.get("series"))
.and_then(Value::as_str).unwrap_or_default().to_string();
let firmware = value.get("ver").and_then(Value::as_str).unwrap_or_default().to_string();
let protocol_version = value.get("protocol").and_then(Value::as_u64)
.or_else(|| value.get("v").and_then(Value::as_u64))
.map(|v| v as u8)
.unwrap_or_else(|| if value.get("tag").is_some() || value.get("nonce").is_some() { 2 } else { 1 });
let now = Utc::now();
Some(Device {
id: format!("gree-{}", mac.to_ascii_lowercase()),
mac,
name,
ip: source.ip().to_string(),
port: source.port(),
protocol_version,
model,
firmware,
key: None,
cid: Some(self.controller_id.clone()),
enabled: true,
simulated: false,
power: false,
mode: "cool".into(),
target_temperature: 24.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
quiet: false,
turbo: false,
light: true,
current_temperature: None,
outdoor_temperature: None,
online: true,
last_seen: Some(now),
last_error: None,
created_at: now,
updated_at: now,
})
}
pub async fn bind(&self, device: &Device) -> Result<String> {
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
let response = self.request(device, &inner, GENERIC_GREE_KEY, true).await?;
let key = response.get("key").and_then(Value::as_str)
.ok_or_else(|| anyhow!("bind response does not contain device key"))?;
if key.is_empty() { bail!("device returned an empty key") }
Ok(key.to_string())
}
pub async fn poll(&self, device: &mut Device) -> Result<()> {
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
let cols = [
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem"
];
let inner = json!({"cols": cols, "mac": device.mac, "t": "status"});
let response = self.request(device, &inner, key, false).await?;
let response_cols = response.get("cols").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?;
let data = response.get("dat").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no dat"))?;
for (name, value) in response_cols.iter().zip(data.iter()) {
let Some(name) = name.as_str() else { continue; };
match name {
"Pow" => device.power = value_as_i64(value) != 0,
"Mod" => device.mode = mode_name(value_as_i64(value)).into(),
"SetTem" => device.target_temperature = value_as_f64(value).clamp(8.0, 32.0),
"WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8,
"SwUpDn" => device.swing_vertical = value_as_i64(value) != 0,
"SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0,
"Quiet" => device.quiet = value_as_i64(value) != 0,
"Tur" => device.turbo = value_as_i64(value) != 0,
"Lig" => device.light = value_as_i64(value) != 0,
"TemSen" => {
let raw = value_as_f64(value);
device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
}
_ => {}
}
}
device.online = true;
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
Ok(())
}
pub async fn command(&self, device: &Device, command: &DeviceCommand) -> Result<Value> {
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
let mut opt = Vec::<&str>::new();
let mut values = Vec::<Value>::new();
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); }
if let Some(v) = command.target_temperature { opt.push("SetTem"); values.push(json!(v.clamp(8.0, 32.0).round() as i64)); }
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
let inner = json!({"opt": opt, "p": values, "t": "cmd"});
self.request(device, &inner, key, false).await
}
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool) -> Result<Value> {
let target: SocketAddr = format!("{}:{}", device.ip, device.port).parse()
.context("invalid device address")?;
let inner_bytes = serde_json::to_vec(inner)?;
let mut outer = json!({
"cid": self.controller_id,
"i": self.next_id(),
"t": "pack",
"tcid": device.mac,
"uid": 0
});
if device.protocol_version >= 2 && !binding {
let encrypted = encrypt_v2(key, &inner_bytes)?;
outer["pack"] = json!(encrypted.ciphertext);
outer["nonce"] = json!(encrypted.nonce);
outer["tag"] = json!(encrypted.tag);
} else {
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
}
let payload = serde_json::to_vec(&outer)?;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.send_to(&payload, target).await?;
let mut buffer = vec![0_u8; 16 * 1024];
let (size, _) = timeout(Duration::from_secs(4), socket.recv_from(&mut buffer))
.await.context("GREE response timeout")??;
let response: Value = serde_json::from_slice(&buffer[..size]).context("invalid GREE JSON response")?;
let pack = response.get("pack").and_then(Value::as_str)
.ok_or_else(|| anyhow!("GREE response does not contain encrypted pack"))?;
let clear = if let (Some(nonce), Some(tag)) = (
response.get("nonce").and_then(Value::as_str),
response.get("tag").and_then(Value::as_str),
) {
decrypt_v2(key, pack, nonce, tag)?
} else {
decrypt_v1(key, pack)?
};
let decoded: Value = serde_json::from_slice(&clear).context("invalid decrypted GREE response")?;
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) {
bail!("GREE device error: {err}")
}
Ok(decoded)
}
}
fn value_as_i64(value: &Value) -> i64 {
value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
}
fn value_as_f64(value: &Value) -> f64 {
value.as_f64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
}
fn mode_name(value: i64) -> &'static str {
match value { 0 => "auto", 1 => "cool", 2 => "dry", 3 => "fan", 4 => "heat", _ => "auto" }
}
fn mode_value(value: &str) -> Result<i64> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
_ => bail!("unsupported mode: {value}"),
}
}
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
if let Some(mut old) = existing {
old.ip = discovered.ip;
old.port = discovered.port;
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" { old.name = discovered.name; }
if !discovered.model.is_empty() { old.model = discovered.model; }
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
old.protocol_version = discovered.protocol_version;
old.online = true;
old.last_seen = Some(Utc::now());
old.last_error = None;
old.updated_at = Utc::now();
old
} else {
let mut new = discovered;
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
new
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod crypto;
pub mod gree;
pub use gree::{GreeClient, merge_discovered};
+196
View File
@@ -0,0 +1,196 @@
//! Centralized SQLite statements used by the controller.
//!
//! Keep SQL in this module so database access code stays focused on mapping,
//! transactions and domain behavior. New queries and schema migrations should
//! be added here instead of embedding SQL strings in other Rust modules.
pub const INIT_SCHEMA: &str = r#"
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
mac TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
ip TEXT NOT NULL,
simulated INTEGER NOT NULL DEFAULT 0,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS zones (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
zone_id TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
indoor_temperature REAL,
outdoor_temperature REAL,
target_temperature REAL NOT NULL,
power INTEGER NOT NULL,
source TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS readings_device_time_idx
ON readings(device_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL,
metadata TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
"#;
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
pub const UPSERT_DEVICE: &str = r#"
INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET
mac=excluded.mac,
name=excluded.name,
ip=excluded.ip,
simulated=excluded.simulated,
payload=excluded.payload,
updated_at=excluded.updated_at
"#;
pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE";
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
pub const UPSERT_ZONE: &str = r#"
INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3)
ON CONFLICT(id) DO UPDATE SET
payload=excluded.payload,
updated_at=excluded.updated_at
"#;
pub const LIST_ZONES: &str =
"SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1";
pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1";
pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1";
pub const UPSERT_SCHEDULE: &str = r#"
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
ON CONFLICT(id) DO UPDATE SET
zone_id=excluded.zone_id,
payload=excluded.payload,
updated_at=excluded.updated_at
"#;
pub const LIST_SCHEDULES: &str =
"SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1";
pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1";
pub const UPSERT_AUTOMATION: &str = r#"
INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3)
ON CONFLICT(id) DO UPDATE SET
payload=excluded.payload,
updated_at=excluded.updated_at
"#;
pub const LIST_AUTOMATIONS: &str =
"SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1";
pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1";
pub const INSERT_READING: &str = r#"
INSERT INTO readings(
device_id,
timestamp,
indoor_temperature,
outdoor_temperature,
target_temperature,
power,
source
)
VALUES(?1,?2,?3,?4,?5,?6,?7)
"#;
pub const LIST_READINGS_BY_DEVICE: &str = r#"
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
FROM readings
WHERE device_id=?1 AND timestamp>=?2
ORDER BY timestamp ASC
LIMIT ?3
"#;
pub const LIST_READINGS_ALL: &str = r#"
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
FROM readings
WHERE timestamp>=?1
ORDER BY timestamp ASC
LIMIT ?2
"#;
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
pub const INSERT_EVENT: &str =
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
pub const LIST_EVENTS: &str =
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
pub const LIST_API_TOKENS: &str =
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
pub const INSERT_API_TOKEN: &str =
"INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)";
pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1";
pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1";
pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'";
pub const UPSERT_RUNTIME_SETTINGS: &str = r#"
INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
updated_at=excluded.updated_at
"#;
+38
View File
@@ -0,0 +1,38 @@
use std::{sync::Arc, time::Instant};
use chrono::Utc;
use serde_json::Value;
use tokio::sync::{broadcast, RwLock};
use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient};
#[derive(Clone)]
pub struct AppState {
pub db: Db,
pub settings: Arc<RwLock<RuntimeSettings>>,
pub config: Arc<Config>,
pub gree: GreeClient,
pub events: broadcast::Sender<ApiEvent>,
pub http: reqwest::Client,
pub started: Instant,
}
impl AppState {
pub fn broadcast(&self, event: impl Into<String>, data: Value) {
let _ = self.events.send(ApiEvent {
event: event.into(),
timestamp: Utc::now(),
data,
});
}
pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) {
if let Err(err) = self.db.log_event(level, kind, message, &metadata) {
tracing::warn!(error=?err, "cannot persist event log");
}
self.broadcast("log.created", serde_json::json!({
"level": level,
"kind": kind,
"message": message,
"metadata": metadata,
}));
}
}
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=GREE Controller
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=gree-controller
Group=gree-controller
WorkingDirectory=/var/lib/gree-controller
EnvironmentFile=-/etc/gree-controller.env
ExecStart=/opt/gree-controller/gree-controller
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/gree-controller
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
+633
View File
@@ -0,0 +1,633 @@
'use strict';
const getCookie = name => {
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
};
const setCookie = (name, value) => {
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=/; SameSite=Lax`;
};
const DEFAULT_LANGUAGE = 'en';
const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE;
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
const app = {
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {},
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB';
const tr = (key, params = {}) => {
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
};
const fmtTemp = value => Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
function applyTheme() {
const resolved = app.theme === 'light' || app.theme === 'dark'
? app.theme
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
const meta = $('#themeColorMeta');
if (meta) meta.content = resolved === 'light' ? '#f5f7f6' : '#0a1110';
const select = $('#themeSelect');
if (select) select.value = app.theme;
drawCurrentChartIfVisible();
}
function applyTranslations() {
document.documentElement.lang = app.language;
$$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); });
$$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); });
$$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); });
$$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); });
$$('[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;
$('#themeSelect').value = app.theme;
const connectionLabel = $('#connectionLabel');
if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`);
renderAll();
if (app.currentView === 'logs') loadLogs();
if (app.currentView === 'history' && app.devices.length) loadHistory();
}
function setLanguage(language) {
const available = app.languages.some(item => item.code === language);
app.language = available ? language : DEFAULT_LANGUAGE;
setCookie('gree_controller_language', app.language);
applyTranslations();
}
function renderLanguageOptions() {
const select = $('#languageSelect');
if (!select) return;
select.innerHTML = app.languages.map(item => {
const label = item.native_name || item.name || item.code.toUpperCase();
return `<option value="${esc(item.code)}">${esc(label)}</option>`;
}).join('');
select.value = app.language;
}
async function loadLanguages() {
try {
const response = await fetch('/lang/index.json', {cache: 'no-cache'});
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
const manifest = await response.json();
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing');
const loaded = await Promise.all(languages.map(async item => {
const packResponse = await fetch(item.path || `/lang/${encodeURIComponent(item.code)}.json`, {cache: 'no-cache'});
if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`);
const pack = await packResponse.json();
return {item, pack};
}));
app.languages = loaded.map(({item}) => item);
app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}]));
app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code]));
app.language = app.languages.some(item => item.code === preferredLanguage)
? preferredLanguage
: (manifest.default || DEFAULT_LANGUAGE);
if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE;
renderLanguageOptions();
} catch (error) {
console.error('Unable to load language packs:', error);
app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}];
app.translations = {[DEFAULT_LANGUAGE]: {}};
app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'};
app.language = DEFAULT_LANGUAGE;
renderLanguageOptions();
}
}
function setTheme(theme) {
app.theme = ['system', 'light', 'dark'].includes(theme) ? theme : 'system';
setCookie('gree_controller_theme', app.theme);
applyTheme();
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
headers.set('Accept', 'application/json');
if (app.token) headers.set('Authorization', `Bearer ${app.token}`);
let body = options.body;
if (body !== undefined && body !== null && typeof body !== 'string') {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(body);
}
const response = await fetch(path, {...options, headers, body});
if (response.status === 401) {
showTokenDialog();
throw new Error(tr('auth.invalid'));
}
if (!response.ok) {
let message = tr('error.http', {status: response.status});
try { message = (await response.json()).error || message; } catch (_) {}
throw new Error(message);
}
if (response.status === 204) return null;
return response.json();
}
function toast(message, error = false) {
const node = $('#toast');
node.textContent = message;
node.className = error ? 'show error' : 'show';
clearTimeout(node._timer);
node._timer = setTimeout(() => node.className = '', 3200);
}
function showTokenDialog() {
const dialog = $('#tokenDialog');
if (!dialog.open) dialog.showModal();
}
async function loadBootstrap(showMessage = false) {
if (app.loading) return;
app.loading = true;
try {
const data = await api('/api/bootstrap');
app.devices = data.devices || [];
app.zones = data.zones || [];
app.schedules = data.schedules || [];
app.automations = data.automations || [];
app.accessTokens = data.access_tokens || [];
app.settings = data.settings || null;
app.system = data.system || {};
renderAll();
if (showMessage) toast(tr('common.updated'));
if ($('#tokenDialog').open) $('#tokenDialog').close();
connectWebSocket();
} catch (error) {
if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true);
} finally {
app.loading = false;
}
}
function renderAll() {
renderSummary();
renderDevices();
renderZones();
renderSchedules();
renderAutomations();
renderAccessTokens();
fillSelects();
renderSettings();
}
function renderSummary() {
const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite);
const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null;
const online = app.devices.filter(d => d.online).length;
const active = app.devices.filter(d => d.power).length;
const demand = app.zones.filter(z => z.enabled && z.demand).length;
$('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}<small>°C</small>`;
const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : '';
$('#summaryText').textContent = app.devices.length
? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix})
: tr('dashboard.empty');
$('#metrics').innerHTML = [
[tr('dashboard.metricOnline'), `${online}/${app.devices.length}`],
[tr('dashboard.metricActive'), active],
[tr('dashboard.metricDemand'), demand],
].map(([label,value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
const error = device.last_error
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
: `<small>${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : `V${device.protocol_version}`}</small>`;
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"></button>
</div>
<div class="temperature-control">
<button data-action="temperature" data-delta="-0.5" data-device="${esc(device.id)}"></button>
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="0.5" data-device="${esc(device.id)}">+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
<div class="mode-row">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"> ${esc(tr('devices.swing'))}</button>
<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>
<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>
</div>
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderDevices() {
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty;
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty;
}
function zoneStrategyLabel(zone) {
if (zone.sensor_source === 'combined') return tr('zones.combinedSource');
if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource');
return tr('zones.greeSource');
}
function zoneControlSourceLabel(source) {
return ({
device: tr('zones.sourceDevice'),
external: tr('zones.sourceExternal'),
combined: tr('zones.sourceCombined'),
device_fallback: tr('zones.sourceFallback'),
device_discrepancy_fallback: tr('zones.sourceDiscrepancy'),
unavailable: tr('zones.sourceUnavailable'),
})[source] || source || tr('zones.sourceUnavailable');
}
function renderZones() {
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => {
const device = app.devices.find(d => d.id === zone.device_id);
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
const sensorDetails = zone.sensor_source === 'device'
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
return `<article class="list-card">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(zone.setpoint)}</strong></div><div class="card-stat"><small>${esc(tr('zones.demand'))}</small><strong>${esc(tr(zone.demand ? 'common.on' : 'common.off'))}</strong></div></div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
<div class="card-footer"><small>${esc(modeLabel(zone.mode))} · ${esc(tr('zones.hysteresis').replace(' °C','').toLowerCase())} ${Number(zone.hysteresis).toFixed(1)}°C</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
}
function renderSchedules() {
const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`));
$('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => {
const zone = app.zones.find(z => z.id === item.zone_id);
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(item.setpoint)}</strong></div></div>
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
function renderAutomations() {
const triggerLabel = item => item.trigger_kind === 'time'
? tr('automations.triggerAt', {time: item.at_time})
: tr(item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow', {temperature: fmtTemp(item.threshold)});
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionDevice = app.devices.find(d => d.id === item.action_device_id);
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: triggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${item.action.mode ? esc(modeLabel(item.action.mode)) : ''}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : ''}</strong></div></div>
<div class="card-footer"><small>${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
function fillSelects() {
const deviceOptions = app.devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
const zoneOptions = app.zones.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => {
const select = $(selector); if (!select) return;
const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current;
});
const zoneSelect = $('#scheduleForm [name=zone_id]');
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
const history = $('#historyDevice');
if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; }
}
function renderAccessTokens() {
const list = $('#accessTokenList');
if (!list) return;
list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => `
<div class="token-row">
<div><strong>${esc(item.name)}</strong><small class="mono">${esc(item.token_prefix)}</small><small>${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}</small></div>
<button type="button" class="danger" data-action="revoke-access-token" data-id="${esc(item.id)}">${esc(tr('actions.revoke'))}</button>
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
}
function renderSettings() {
if (!app.settings) return;
const form = $('#settingsForm');
form.controller_id.value = app.settings.controller_id || '';
form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15;
form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5;
form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000';
form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000;
form.simulator_enabled.checked = !!app.settings.simulator_enabled;
form.ha_url.value = app.settings.home_assistant?.url || '';
form.ha_token.value = '';
form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken');
form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || '';
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
}
function formatDuration(seconds) {
const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60);
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
}
function showView(name) {
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' && ['schedules','automations','settings','logs'].includes(name))));
window.scrollTo({top: 0, behavior: 'smooth'});
if (name === 'history' && app.devices.length) loadHistory();
if (name === 'logs') loadLogs();
}
async function sendDeviceCommand(id, command) {
try {
const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command});
updateDevice(device); renderAll();
} catch (error) { toast(error.message, true); }
}
function updateDevice(device) {
const index = app.devices.findIndex(item => item.id === device.id);
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
}
async function deleteEntity(type, id, labelKey) {
if (!confirm(tr('confirm.delete', {label: tr(labelKey)}))) return;
try { await api(`/api/${type}/${encodeURIComponent(id)}`, {method:'DELETE'}); await loadBootstrap(); toast(tr('common.removed')); }
catch (error) { toast(error.message, true); }
}
function openDialog(id) {
fillSelects();
const dialog = document.getElementById(id);
if (dialog && !dialog.open) dialog.showModal();
}
function updateZoneSensorFields() {
const form = $('#zoneForm');
if (!form) return;
const external = form.sensor_source.value !== 'device';
$('#externalSensorFields').hidden = !external;
form.ha_entity_id.required = external;
}
function populateZone(id) {
const item = app.zones.find(v => v.id === id); if (!item) return;
const form = $('#zoneForm'); form.reset(); fillSelects();
Object.entries(item).forEach(([key,value]) => { if (form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100);
form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3);
form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog');
}
function populateSchedule(id) {
const item = app.schedules.find(v => v.id === id); if (!item) return;
const form = $('#scheduleForm'); form.reset(); fillSelects();
['id','name','zone_id','start_time','end_time','setpoint'].forEach(key => form.elements[key].value = item[key]);
form.enabled.checked = item.enabled;
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
openDialog('scheduleDialog');
}
function populateAutomation(id) {
const item = app.automations.find(v => v.id === id); if (!item) return;
const form = $('#automationForm'); form.reset(); fillSelects();
['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
form.action_power.value = item.action.power == null ? '' : String(item.action.power);
form.action_mode.value = item.action.mode || '';
form.action_target_temperature.value = item.action.target_temperature ?? '';
form.enabled.checked = item.enabled;
openDialog('automationDialog');
}
async function loadHistory() {
const deviceId = $('#historyDevice').value;
if (!deviceId) return drawChart([]);
try {
const data = await api(`/api/readings?device_id=${encodeURIComponent(deviceId)}&hours=${encodeURIComponent($('#historyHours').value)}&limit=2500`);
drawChart(data.readings || []);
} catch (error) { toast(error.message, true); }
}
function drawChart(readings) {
const canvas = $('#historyChart');
const rect = canvas.getBoundingClientRect();
const width = Math.max(680, Math.floor(rect.width || 680));
const height = 340;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr; canvas.height = height * dpr;
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);
const styles = getComputedStyle(document.documentElement);
const text = styles.getPropertyValue('--muted').trim(), grid = styles.getPropertyValue('--grid').trim(), indoor = styles.getPropertyValue('--accent').trim(), target = styles.getPropertyValue('--warning').trim();
ctx.clearRect(0,0,width,height);
const pad = {left:52,right:20,top:22,bottom:40};
if (!readings.length) { ctx.fillStyle = text; ctx.font = '14px system-ui'; ctx.textAlign='center'; ctx.fillText(tr('history.noData'), width/2, height/2); return; }
const values = readings.flatMap(r => [r.indoor_temperature, r.target_temperature]).filter(Number.isFinite);
let min = Math.floor(Math.min(...values) - 1), max = Math.ceil(Math.max(...values) + 1); if (max-min < 4) { min -= 2; max += 2; }
const x = i => pad.left + i / Math.max(1, readings.length-1) * (width-pad.left-pad.right);
const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom);
ctx.lineWidth = 1; ctx.font = '11px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid;
for (let i=0;i<=5;i++) { const value = min+(max-min)*i/5, py=y(value); ctx.beginPath(); ctx.moveTo(pad.left,py); ctx.lineTo(width-pad.right,py); ctx.stroke(); ctx.textAlign='right'; ctx.fillText(`${value.toFixed(1)}°`,pad.left-8,py+4); }
const ticks = Math.min(5, readings.length-1);
for (let i=0;i<=ticks;i++) { const idx=Math.round(i*(readings.length-1)/Math.max(1,ticks)), px=x(idx); ctx.textAlign='center'; ctx.fillText(new Date(readings[idx].timestamp).toLocaleString(locale(),{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),px,height-13); }
function line(field, color, dashed=false) {
ctx.beginPath(); ctx.strokeStyle=color; ctx.lineWidth=2.3; ctx.setLineDash(dashed?[6,5]:[]); let started=false;
readings.forEach((row,index)=>{ const value=Number(row[field]); if(!Number.isFinite(value)) return; if(!started){ctx.moveTo(x(index),y(value));started=true;}else ctx.lineTo(x(index),y(value)); }); ctx.stroke(); ctx.setLineDash([]);
}
line('target_temperature', target, true); line('indoor_temperature', indoor, false);
canvas._readings = readings;
}
function drawCurrentChartIfVisible() {
const canvas = $('#historyChart');
if (app.currentView === 'history' && canvas?._readings) drawChart(canvas._readings);
}
async function loadLogs() {
try {
const data = await api('/api/events?limit=150');
const logs = data.events || [];
$('#logList').innerHTML = logs.length
? logs.map(item => `<div class="log-row ${esc(item.level)}"><time>${esc(new Date(item.timestamp).toLocaleTimeString(locale()))}</time><span class="kind">${esc(item.kind)}</span><span class="message">${esc(item.message)}</span></div>`).join('')
: `<div class="empty"><strong>${esc(tr('logs.emptyTitle'))}</strong>${esc(tr('logs.emptyText'))}</div>`;
} catch (error) { toast(error.message, true); }
}
function connectWebSocket() {
if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return;
clearTimeout(app.wsTimer);
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws;
ws.onopen = () => { app.connectionStatus = 'connected'; $('#connectionLabel').textContent = tr('status.connected'); };
ws.onclose = () => { app.connectionStatus = 'disconnected'; $('#connectionLabel').textContent = tr('status.disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
ws.onerror = () => { app.connectionStatus = 'connectionError'; $('#connectionLabel').textContent = tr('status.connectionError'); };
ws.onmessage = event => {
try {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; renderAll(); return;
}
const data = message.data || {};
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); }
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); }
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); }
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderZones(); }
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); }
else if (message.event === 'log.created' && app.currentView === 'logs') loadLogs();
} catch (_) {}
};
}
document.addEventListener('click', async event => {
const button = event.target.closest('button'); if (!button) return;
if (button.dataset.nav) {
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
return;
}
if (button.dataset.go) { $('#moreDialog').close(); showView(button.dataset.go); return; }
if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); openDialog(button.dataset.open); return; }
if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; }
const action = button.dataset.action; if (!action) return;
const device = app.devices.find(v => v.id === button.dataset.device);
if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,32)});
if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true});
if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)});
if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]});
if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
if (action === 'edit-zone') return populateZone(button.dataset.id);
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule');
if (action === 'edit-automation') return populateAutomation(button.dataset.id);
if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation');
if (action === 'revoke-access-token') {
if (!confirm(tr('confirm.revokeToken'))) return;
try {
await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'});
app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id);
renderAccessTokens();
toast(tr('toast.tokenRevoked'));
} catch (error) { toast(error.message, true); }
return;
}
});
$('#refreshButton').addEventListener('click', () => loadBootstrap(true));
$('#discoverButton').addEventListener('click', async event => {
const button=event.currentTarget; button.disabled=true; button.textContent=tr('actions.discovering');
try { const result=await api('/api/discovery',{method:'POST',body:{}}); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); }
catch(error){toast(error.message,true);} finally{button.disabled=false;button.textContent=tr('actions.discover');}
});
$('#historyRefresh').addEventListener('click', loadHistory);
$('#historyDevice').addEventListener('change', loadHistory);
$('#historyHours').addEventListener('change', loadHistory);
$('#logsRefresh').addEventListener('click', loadLogs);
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
$('#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();
});
$('#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);}
});
$('#zoneForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id; const body={name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,mode:raw.mode,setpoint:Number(raw.setpoint),hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:Number(raw.max_sensor_difference)};
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);}
});
$('#scheduleForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,setpoint:Number(raw.setpoint)};
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);}
});
$('#automationForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(raw.action_target_temperature!=='') action.target_temperature=Number(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:Number(raw.threshold),at_time:raw.at_time||null,action_device_id:raw.action_device_id,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);}
});
$('#settingsForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const body={controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id}};
try { app.settings=await api('/api/settings',{method:'PUT',body}); renderSettings(); toast(tr('common.saved')); }
catch(error){toast(error.message,true);}
});
$('#createAccessToken').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}});
if (result.item) app.accessTokens.unshift(result.item);
renderAccessTokens();
$('#generatedAccessToken').value = result.token || '';
openDialog('generatedTokenDialog');
toast(tr('toast.tokenCreated'));
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#copyAccessToken').addEventListener('click', async () => {
const input = $('#generatedAccessToken');
try {
await navigator.clipboard.writeText(input.value);
toast(tr('toast.tokenCopied'));
} catch (_) {
input.select();
document.execCommand('copy');
toast(tr('toast.tokenCopied'));
}
});
$('#haTest').addEventListener('click', async () => {
const form=$('#settingsForm');
if (form.ha_token.value || form.ha_url.value !== (app.settings?.home_assistant?.url || '')) {
form.requestSubmit(); await new Promise(resolve=>setTimeout(resolve,250));
}
try { const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:form.ha_entity_id.value||null}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); }
catch(error){toast(error.message,true);}
});
window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); });
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); });
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{}));
async function startApplication() {
applyTheme();
await loadLanguages();
applyTranslations();
updateZoneSensorFields();
await loadBootstrap();
}
startApplication().catch(error => console.error('Application startup failed:', error));
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="30" fill="#0d1715"/>
<path d="M64 25v43" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
<path d="M42 42a39 39 0 1 0 44 0" fill="none" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0a1110" id="themeColorMeta">
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
<script>
(() => {
const getCookie = name => document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=')[1];
const requested = decodeURIComponent(getCookie('gree_controller_theme') || 'system');
const resolved = requested === 'light' || requested === 'dark'
? requested
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
})();
</script>
<title>GREE Controller</title>
</head>
<body>
<header class="topbar">
<div class="brand">
<div><strong>GREE Controller</strong><small id="connectionLabel" data-i18n="status.connecting">Connecting…</small></div>
</div>
<div class="top-actions">
<select class="toolbar-select" id="languageSelect" data-i18n-aria="controls.language" aria-label="Language">
<option value="en">English</option>
</select>
<select class="toolbar-select theme-select" id="themeSelect" data-i18n-aria="controls.theme" aria-label="Theme">
<option value="system" data-i18n="theme.system">System</option>
<option value="light" data-i18n="theme.light">Light</option>
<option value="dark" data-i18n="theme.dark">Dark</option>
</select>
<button class="icon-button" id="refreshButton" data-i18n-title="actions.refresh" data-i18n-aria="actions.refresh" title="Refresh" aria-label="Refresh"></button>
<button class="primary compact" id="discoverButton" data-i18n="actions.discover">Discover</button>
</div>
</header>
<main>
<section class="view active" data-view="dashboard">
<div class="hero">
<div><span class="eyebrow" data-i18n="dashboard.home">Home</span><h1 data-i18n="dashboard.title">Comfort under control</h1><p id="summaryText" data-i18n="dashboard.loading">Loading devices…</p></div>
<div class="hero-temp" id="heroTemperature">--<small>°C</small></div>
</div>
<div class="metrics" id="metrics"></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Quick control</h2></div></div>
<div class="device-grid" id="dashboardDevices"></div>
</section>
<section class="view" data-view="devices">
<div class="section-heading">
<div><span class="eyebrow" data-i18n="devices.lan">LAN network</span><h1 data-i18n="nav.devices">Devices</h1></div>
<button class="secondary" data-open="deviceDialog" data-i18n="devices.addManual">Add manually</button>
</div>
<div class="device-grid" id="deviceList"></div>
</section>
<section class="view" data-view="zones">
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div>
<p class="lead" data-i18n="zones.description">A zone controls a device using temperature, hysteresis and minimum cycle time.</p>
<div class="list-grid" id="zoneList"></div>
</section>
<section class="view" data-view="schedules">
<div class="section-heading"><div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span><h1 data-i18n="nav.schedules">Schedules</h1></div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button></div>
<div class="list-grid" id="scheduleList"></div>
</section>
<section class="view" data-view="automations">
<div class="section-heading"><div><span class="eyebrow" data-i18n="automations.rules">Rules</span><h1 data-i18n="nav.automations">Automations</h1></div><button class="secondary" data-open="automationDialog" data-i18n="actions.add">Add</button></div>
<div class="list-grid" id="automationList"></div>
</section>
<section class="view" data-view="history">
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Temperature history</h1></div></div>
<div class="panel chart-panel">
<div class="chart-toolbar">
<select id="historyDevice"></select>
<select id="historyHours">
<option value="6" data-i18n="history.6h">6 hours</option>
<option value="24" selected data-i18n="history.24h">24 hours</option>
<option value="168" data-i18n="history.7d">7 days</option>
<option value="720" data-i18n="history.30d">30 days</option>
</select>
<button class="secondary" id="historyRefresh" data-i18n="actions.show">Show</button>
</div>
<div class="chart-wrap"><canvas id="historyChart" width="1000" height="420"></canvas></div>
<div class="legend"><span><i class="dot indoor"></i><span data-i18n="common.temperature">Temperature</span></span><span><i class="dot target"></i><span data-i18n="common.target">Target</span></span></div>
</div>
</section>
<section class="view" data-view="settings">
<div class="section-heading"><div><span class="eyebrow" data-i18n="settings.system">System</span><h1 data-i18n="nav.settings">Settings</h1></div></div>
<form class="panel form-grid" id="settingsForm">
<h3 data-i18n="settings.controller">Controller</h3>
<label><span data-i18n="settings.clientId">Client identifier</span><input name="controller_id" required></label>
<label><span data-i18n="settings.pollInterval">Poll interval (s)</span><input type="number" name="poll_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.zoneInterval">Zone interval (s)</span><input type="number" name="zone_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.broadcast">Broadcast address</span><input name="discovery_broadcast" placeholder="255.255.255.255:7000" required></label>
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
<hr>
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="wide"><span data-i18n="settings.defaultEntity">Default entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
<hr>
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
<p class="field-note wide" data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p>
<div class="wide token-manager">
<div id="accessTokenList" class="token-list"></div>
<div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div>
</div>
</form>
<div class="panel system-panel" id="systemInfo"></div>
</section>
<section class="view" data-view="logs">
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
<div class="panel log-list" id="logList"></div>
</section>
</main>
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
<button class="active" data-nav="dashboard"><span></span><b data-i18n="nav.dashboard">Dashboard</b></button>
<button data-nav="devices"><span></span><b data-i18n="nav.devices">Devices</b></button>
<button data-nav="zones"><span></span><b data-i18n="nav.zones">Zones</b></button>
<button data-nav="history"><span></span><b data-i18n="nav.history">History</b></button>
<button data-nav="more"><span>•••</span><b data-i18n="nav.more">More</b></button>
</nav>
<dialog id="moreDialog" class="sheet">
<div class="dialog-head"><h2 data-i18n="nav.more">More</h2><button data-close>×</button></div>
<div class="menu-list">
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span></span></button>
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span></span></button>
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span></span></button>
</div>
</dialog>
<dialog id="deviceDialog">
<form method="dialog" id="deviceForm" class="dialog-form">
<div class="dialog-head"><h2 data-i18n="devices.add">Add device</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
<label><span>MAC / CID</span><input name="mac" required placeholder="AABBCCDDEEFF"></label>
<label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" required placeholder="192.168.1.50"></label>
<div class="two"><label><span>Port</span><input type="number" name="port" value="7000" min="1" max="65535"></label><label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label></div>
<label><span data-i18n="devices.keyOptional">Key (optional)</span><input name="key" data-i18n-placeholder="devices.keyPlaceholder" placeholder="Leave empty for automatic bind"></label>
<label class="check"><input type="checkbox" name="simulated"> <span data-i18n="devices.simulated">Simulated device</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.add">Add</button></div>
</form>
</dialog>
<dialog id="zoneDialog">
<form method="dialog" id="zoneForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
<div class="two"><label><span data-i18n="common.mode">Mode</span><select name="mode"><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option></select></label><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="32" name="setpoint" value="23"></label></div>
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="number" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
<div id="externalSensorFields">
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="number" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
<p class="field-note" data-i18n="zones.sensorHelp">The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
</div>
<div class="two"><label><span data-i18n="zones.minOn">Min. ON (s)</span><input type="number" min="0" name="min_on_seconds" value="180"></label><label><span data-i18n="zones.minOff">Min. OFF (s)</span><input type="number" min="0" name="min_off_seconds" value="180"></label></div>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="scheduleDialog">
<form method="dialog" id="scheduleForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="common.schedule">Schedule</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.night" placeholder="Night"></label>
<label><span data-i18n="common.zone">Zone</span><select name="zone_id" required></select></label>
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
<label><span data-i18n="common.temperatureC">Temperature °C</span><input type="number" name="setpoint" step="0.1" min="8" max="32" value="23" required></label>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="automationDialog">
<form method="dialog" id="automationForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="common.automation">Automation</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="automations.namePlaceholder" placeholder="Emergency cooling"></label>
<label><span data-i18n="automations.trigger">Trigger</span><select name="trigger_kind"><option value="temperature_above" data-i18n="automations.tempAbove">Temperature above</option><option value="temperature_below" data-i18n="automations.tempBelow">Temperature below</option><option value="time" data-i18n="automations.time">Time</option></select></label>
<label><span data-i18n="automations.measurementDevice">Measurement device</span><select name="trigger_device_id"></select></label>
<div class="two"><label><span data-i18n="automations.thresholdC">Threshold °C</span><input type="number" step="0.1" name="threshold" value="27"></label><label><span data-i18n="automations.time">Time</span><input type="time" name="at_time" value="08:00"></label></div>
<hr><h3 data-i18n="automations.action">Action</h3>
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="32" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="generatedTokenDialog" class="auth-dialog">
<div class="dialog-form">
<div class="dialog-head"><h2 data-i18n="settings.tokenCreated">Token created</h2><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="settings.tokenCreatedHint">Copy this token now. It will not be shown again.</p>
<label><span data-i18n="auth.token">Access token</span><input id="generatedAccessToken" class="mono" readonly></label>
<div class="form-actions"><button type="button" class="secondary" id="copyAccessToken" data-i18n="actions.copy">Copy</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
</div>
</dialog>
<dialog id="tokenDialog" class="auth-dialog">
<form method="dialog" id="tokenForm" class="dialog-form">
<div class="brand large"><div><strong>GREE Controller</strong><small data-i18n="auth.required">Authentication required</small></div></div>
<label><span data-i18n="auth.token">Access token</span><input type="password" name="token" required autocomplete="current-password"></label>
<button class="primary" type="submit" data-i18n="auth.connect">Connect</button>
</form>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "GREE Controller",
"short_name": "GREE",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f7f6",
"theme_color": "#0d1715",
"icons": [{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]
}
+234
View File
@@ -0,0 +1,234 @@
:root {
color-scheme: dark;
--bg: #0a1110;
--surface: #111d1a;
--surface-2: #172622;
--surface-muted: #14201d;
--input-bg: #0c1614;
--nav-bg: #0d1715;
--dialog-bg: #101c19;
--line: rgba(255,255,255,.10);
--text: #f3faf7;
--muted: #91aaa0;
--accent: #5ee2a0;
--accent-strong: #28c779;
--accent-text: #092218;
--danger: #ff8b8b;
--warning: #f6c866;
--backdrop: rgba(1,8,6,.72);
--grid: rgba(255,255,255,.08);
--radius: 22px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f5f7f6;
--surface: #ffffff;
--surface-2: #edf2ef;
--surface-muted: #f0f4f2;
--input-bg: #ffffff;
--nav-bg: #ffffff;
--dialog-bg: #ffffff;
--line: rgba(19,42,33,.14);
--text: #14221d;
--muted: #62756d;
--accent: #20b86b;
--accent-strong: #149557;
--accent-text: #ffffff;
--danger: #c94646;
--warning: #b77900;
--backdrop: rgba(18,33,27,.28);
--grid: rgba(19,42,33,.10);
}
* { box-sizing: border-box; }
html, body { background: var(--bg); }
body { margin: 0; min-height: 100vh; color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; }
.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 72px; padding: 12px max(18px, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 94%, transparent); backdrop-filter: blur(14px); }
.brand { display: flex; align-items: center; min-width: 0; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 15px; letter-spacing: -.01em; white-space: nowrap; }
.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; }
.brand.large { margin-bottom: 1rem; }
.brand.large strong { font-size: 21px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
.toolbar-select { width: auto; min-width: 58px; min-height: 40px; padding: 7px 28px 7px 10px; border-radius: 11px; }
.theme-select { min-width: 94px; }
button { border: 0; border-radius: 13px; padding: 11px 15px; color: var(--text); background: var(--surface-2); transition: transform .15s ease, background .15s ease, opacity .15s ease; }
button:active { transform: scale(.97); }
.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; }
.primary:hover { background: var(--accent-strong); }
.secondary { border: 1px solid var(--line); background: var(--surface-muted); font-weight: 650; }
.secondary:hover { background: var(--surface-2); }
.danger { color: var(--danger); }
.compact { padding: 10px 14px; }
.icon-button { width: 40px; height: 40px; padding: 0; font-size: 22px; background: transparent; }
main { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 108px; }
.view { display: none; animation: reveal .2s ease; }
.view.active { display: block; }
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.045em; }
h2 { margin-bottom: 0; font-size: clamp(23px, 4vw, 31px); letter-spacing: -.035em; }
h3 { margin-bottom: 10px; }
.eyebrow { color: var(--accent); font-size: 11px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
.lead { max-width: 690px; color: var(--muted); line-height: 1.55; }
.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 18px; min-height: 210px; padding: clamp(24px, 6vw, 44px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 30px; background: var(--surface); }
.hero::after { content: ""; position: absolute; right: -80px; bottom: -120px; width: 220px; height: 220px; border: 34px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
.hero h1 { margin: 5px 0 8px; }
.hero p { margin: 0; color: var(--muted); }
.hero-temp { z-index: 1; white-space: nowrap; font-size: clamp(48px, 12vw, 88px); font-weight: 250; letter-spacing: -.075em; }
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 600; letter-spacing: 0; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 14px 0 34px; }
.metric { padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
.metric span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; }
.metric strong { font-size: 24px; letter-spacing: -.03em; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 8px 0 18px; }
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 14px; }
.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
.device-card { overflow: hidden; padding: 20px; }
.device-card.off { opacity: .86; }
.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.device-title { min-width: 0; }
.device-title h3 { overflow: hidden; margin: 0 0 4px; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; }
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--danger); }
.status.online::before { background: var(--accent); }
.power-button { display: grid; place-items: center; flex: 0 0 auto; width: 46px; height: 46px; padding: 0; border-radius: 50%; color: var(--muted); font-size: 21px; }
.power-button.on { background: var(--accent); color: var(--accent-text); }
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 24px 0 17px; }
.temperature-control button { width: 42px; height: 42px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; font-size: 22px; }
.target-temp { min-width: 106px; text-align: center; font-size: 48px; font-weight: 300; letter-spacing: -.06em; }
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 15px; font-weight: 650; }
.current-line { display: flex; justify-content: center; gap: 6px; margin: -10px 0 18px; color: var(--muted); font-size: 12px; }
.current-line strong { color: var(--text); }
.mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; }
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; }
.mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; }
.mode-row button.active, .fan-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
.device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; }
.device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.card-menu { display: flex; gap: 5px; }
.card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; }
.list-card { padding: 19px; }
.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.list-card h3 { margin: 0 0 5px; font-size: 18px; }
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; }
.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; }
.card-stat small, .card-stat strong { display: block; }
.card-stat small { margin-bottom: 5px; color: var(--muted); font-size: 10px; }
.card-stat strong { font-size: 15px; }
.empty { grid-column: 1/-1; padding: 38px 24px; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); text-align: center; }
.empty strong { display: block; margin-bottom: 7px; color: var(--text); }
.panel { padding: 20px; }
.chart-panel { overflow: hidden; }
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 18px; }
.chart-toolbar select { flex: 1; min-width: 140px; }
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; }
#historyChart { width: 100%; min-width: 680px; height: 340px; }
.legend { display: flex; gap: 18px; margin-top: 10px; color: var(--muted); font-size: 12px; }
.legend > span { display: flex; align-items: center; gap: 7px; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
.dot.target { background: var(--warning); }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; }
.form-grid hr, .dialog-form hr { width: 100%; border: 0; border-top: 1px solid var(--line); }
label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; }
input, select { width: 100%; min-height: 45px; border: 1px solid var(--line); border-radius: 12px; outline: 0; padding: 10px 12px; color: var(--text); background: var(--input-bg); }
input:focus, select:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; }
.check input { width: 19px; min-height: 19px; accent-color: var(--accent); }
.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; }
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
.system-panel strong { color: var(--text); }
.log-list { display: grid; gap: 2px; padding: 8px; }
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 12px; }
.log-row:last-child { border-bottom: 0; }
.log-row time, .log-row .kind { color: var(--muted); }
.log-row.error .kind { color: var(--danger); }
.log-row.warn .kind { color: var(--warning); }
.bottom-nav { position: fixed; z-index: 30; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, 1fr); padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 96%, transparent); backdrop-filter: blur(14px); }
.bottom-nav button { display: grid; place-items: center; gap: 3px; padding: 7px 2px; border-radius: 12px; color: var(--muted); background: transparent; font-size: 10px; }
.bottom-nav button span { font-size: 20px; line-height: 1; }
.bottom-nav button b { font-weight: 650; }
.bottom-nav button.active { color: var(--accent); }
dialog { width: min(540px, calc(100% - 24px)); max-height: min(88vh, 760px); overflow: auto; border: 1px solid var(--line); border-radius: 24px; padding: 0; color: var(--text); background: var(--dialog-bg); }
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(4px); }
.dialog-form { display: grid; gap: 14px; padding: 20px; }
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dialog-head h2 { font-size: 23px; }
.dialog-head button { width: 38px; height: 38px; padding: 0; background: transparent; color: var(--muted); font-size: 25px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
#externalSensorFields { display: grid; gap: 12px; }
.field-note { margin: -4px 0 0; color: var(--muted); font-size: .8rem; line-height: 1.45; }
.sensor-detail { margin-top: 10px; color: var(--muted); font-size: .8rem; line-height: 1.45; }
fieldset { border: 1px solid var(--line); border-radius: 14px; padding: 10px; }
legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.days label { display: grid; place-items: center; gap: 5px; padding: 6px 2px; border-radius: 8px; background: var(--surface-muted); font-size: 10px; }
.days input { width: 16px; min-height: 16px; accent-color: var(--accent); }
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 10px; }
.sheet .dialog-head { padding: 18px 18px 5px; }
.menu-list { display: grid; padding: 8px 10px 12px; }
.menu-list button { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 9px; background: transparent; text-align: left; }
.menu-list button:last-child { border-bottom: 0; }
.auth-dialog { width: min(420px, calc(100% - 30px)); }
#toast { position: fixed; z-index: 100; right: 18px; bottom: 92px; max-width: min(380px, calc(100% - 36px)); transform: translateY(15px); opacity: 0; pointer-events: none; padding: 12px 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); transition: .2s ease; font-size: 13px; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); }
@media (min-width: 900px) {
.bottom-nav { top: 92px; right: auto; bottom: auto; left: max(16px, calc((100vw - 1380px)/2)); grid-template-columns: 1fr; width: 90px; border: 1px solid var(--line); border-radius: 22px; padding: 9px; background: var(--surface); }
.bottom-nav button { padding: 10px 4px; }
main { padding-left: 122px; padding-bottom: 50px; }
}
@media (max-width: 720px) {
.topbar { flex-wrap: wrap; }
.top-actions { width: 100%; }
.top-actions .primary { margin-left: auto; }
}
@media (max-width: 620px) {
main { padding: 18px 13px 105px; }
.hero { min-height: 190px; padding: 23px; }
.hero-temp { font-size: 52px; }
.metrics { gap: 7px; }
.metric { padding: 14px 10px; }
.metric strong { font-size: 20px; }
.section-heading { align-items: center; }
.section-heading button { padding: 9px 11px; font-size: 12px; }
.form-grid { grid-template-columns: 1fr; }
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; }
.log-row { grid-template-columns: 62px 1fr; }
.log-row .message { grid-column: 1/-1; }
.theme-select { min-width: 86px; }
}
@media (max-width: 410px) {
.topbar { padding-inline: 12px; }
.brand strong { font-size: 13px; }
.hero { align-items: start; flex-direction: column; }
.hero-temp { align-self: flex-end; margin-top: -15px; }
.two { grid-template-columns: 1fr; }
.toolbar-select { min-width: 54px; }
.theme-select { min-width: 82px; }
}
.token-manager { display: grid; gap: 12px; }
.token-list { display: grid; gap: 8px; }
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-muted); }
.token-row > div { min-width: 0; display: grid; gap: 3px; }
.token-row strong { color: var(--text); font-size: 13px; }
.token-row small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.empty.compact { padding: 18px; }
@media (max-width: 620px) {
.token-row { align-items: stretch; flex-direction: column; }
.token-row button { width: 100%; }
}
+10
View File
@@ -0,0 +1,10 @@
const CACHE = 'gree-controller-v5';
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
event.respondWith(fetch(event.request).then(response => {
const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response;
}).catch(() => caches.match(event.request).then(response => response || caches.match('/'))));
});