This commit is contained in:
Mateusz Gruszczyński
2026-08-24 16:12:31 +02:00
parent 83f744e2cb
commit 66a5d6e5e9
22 changed files with 686 additions and 103 deletions
+10 -3
View File
@@ -1,11 +1,18 @@
# GREE Controller v0.5.3 - build and validation report
# GREE Controller v0.5.4 - build and validation report
## Scope
Version 0.5.3 builds on 0.5.2 with UI consistency fixes, quiet-hours control, a flow-board simulator, event-log retention and Home Assistant sensor aliases.
Version 0.5.4 builds on 0.5.3 with Home Assistant sensor-read fixes, full zone climate mode control, GREE capability discovery and native Sleep support during night mode.
## Implemented changes
- Home Assistant sensor aliases are now presentation-only end-to-end: backend reads resolve aliases back to the original `entity_id`, settings are canonicalized, and zone/outdoor polling records the real entity ID.
- Outdoor temperature now falls back to the median outdoor reading from online GREE units when the configured Home Assistant outdoor sensor is unavailable.
- GREE optional capabilities are discovered from status properties (`Lig`, `Quiet`, `Tur`, `Air`, `Blo`, `Health`, `SwhSlp`) and persisted with each device.
- Supported unit features are shown in the Web UI and exposed as Home Assistant switches for panel light, Quiet, Turbo, X-FAN, Air, Health and Sleep.
- Night mode can use native GREE Sleep in the same command as the standby setpoint, fan limit and Quiet when the unit supports it.
- Home Assistant now exposes every controller zone as a full `climate` entity with Off / Auto (follow house mode) / Cool / Heat plus current and target temperature. Existing zone number/switch entities are retained.
- Decorative icons were removed from Settings block headers.
- Replaced the textual Connected/Disconnected label with a green/amber/red status dot in the top-left brand header.
- Fixed History filter layout so Zone/Device/Sensor and time-range controls keep equal heights and do not stretch after changing tabs.
- Reworked the Simulator into a Node-RED-style dotted flow board with connected live nodes for sensor input, thermostat target, decision, unit command and next event.
@@ -45,7 +52,7 @@ Version 0.5.3 builds on 0.5.2 with UI consistency fixes, quiet-hours control, a
## Rust compiler note
The packaging environment does not contain `rustc`, `cargo` or `rustfmt`, so a Rust compile cannot be executed here. Regression tests were added for unchanged-command filtering and history compaction; the target LXC update/install scripts remain the authoritative compile gate and run:
The packaging environment does not contain `rustc`, `cargo` or `rustfmt`, so a Rust compile cannot be executed here. Rust unit tests cover unchanged-command filtering, history compaction, HA alias resolution, GREE outdoor fallback, night/native-Sleep decisions and combined standby command payloads; they cannot be executed in this packaging environment. The target LXC update/install scripts remain the authoritative compile gate and run:
```bash
cargo test --all-targets
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.5.3"
version = "0.5.4"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.5.3"
version = "0.5.4"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+22 -22
View File
@@ -1,34 +1,34 @@
520d003bd881ccfd193bd59ed1264466903070b688bef23f9477aa7728207e7a ./.env.example
e027a3bf37a7917e249ebffb980d43b5e18c70e5931113271975cbeda81a88bd ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
148609fdc0b1465895f6c5258258ccaa240cf7feb52ac1dbd7bca35e635890be ./BUILD_REPORT.md
eaf6bdc44eb92f2372a8da62fa60e88cfec3b5e07bc295866ee20899ecb3f436 ./Cargo.lock
a6cbb8074f6d065adcce27198001008c6fa30b8cd0d124de38637e2eedf06927 ./Cargo.toml
7e0bbddcccea0726bdbf7c93a0fa3a892c1d60279b6da8d992a55a7183522b01 ./BUILD_REPORT.md
ce1d4a2d05be90ffecc39af85cdf5383b08484c3a27482e02f387030075db17e ./Cargo.lock
d9e0c071ee11f26bd8e804e6cff976339982ebca45dad2f03614e147c35df327 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
fb59b28f19e10c01349ba7e271072b9c6950e5596c97cc93ed4c0214620eaa6a ./README.md
e7b879527c9d1eb7d6bbd054813e709026777e4a79e62b4c8912d2d5aa5e7ff3 ./README.md
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
1ed94830fa34a957949ebb8d447c59b437f0460ac58710e10a14d26d0d6f1ca0 ./docs/API.md
f00f7475d690694c837e29a618c7737199515bd7a64a5a3be5f44291fe73df0c ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md
3996549f159bb1bb07139cd5afa218d948326b192123a4e7a8ace15fe1e18be9 ./home-assistant/README.md
cf889651576a0860c3dc383c255aa011306f82d6287533617c523bcc41caca21 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py
fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assistant/custom_components/gree_controller/climate.py
b0314469ddb7c69ee4a96c677815af30cb97713e93911a05597f2fdc3d10f489 ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
48e66790617ee5ce8299bb388cc603a6bc8e6b6b29d2b1d05ef69d3b15e55079 ./home-assistant/custom_components/gree_controller/manifest.json
b3ac1d74c2f8decdc0dcb9689d3a62bea803a9533164929c09d97922c08c605a ./home-assistant/custom_components/gree_controller/manifest.json
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
59632b92ed799234c632324b00e9a48e89ee06a5c49b8c27d29e495ee42e40e9 ./home-assistant/custom_components/gree_controller/switch.py
7c23374d1c82e4b361089386ff89265322b80def0f4db9d3fd2c039867033105 ./home-assistant/custom_components/gree_controller/switch.py
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
40a383bb95be5d2de716262002dc4e2dc738730484919e0164c678098e745866 ./lang/en.json
299a4e8404b6e14d1c545790d04e8619237f5ceecbcceafe646427897e8070c5 ./lang/pl.json
e824a016cff1fa0411ab456b74f3cfe597546072c9e334ef92de69f86a868f48 ./lang/en.json
93d26be77deb820dbce29484db8f2ef6c56b5b05a451c4e9d57013e43aa88395 ./lang/pl.json
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
@@ -41,24 +41,24 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
df42f5610d74bcd6c43766c26ae8e23197a37483dba69c044fe85e44a06e4ef4 ./src/api.rs
94e05b5bdc7c0eeb4bec823f83ae36246836c18f2b164ebf1629a82723840eb5 ./src/config.rs
ca63498714f3e154fd98c0c2157ab355bf36fc2208d7b3f82c201e153749fb04 ./src/api.rs
31ef3ddbc096e580e9212486683e4db86998322ee87190c61b0cab5ae6bc8fc9 ./src/config.rs
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
9ba09db2faef9f03e7d3cd57a618dedf5a8caebb81ece8ee683521d5a2d4468d ./src/engine.rs
c2ac8e8b4acc2bd15e9c41af5a003b7ced4424346617946e387ec57ce9cb2583 ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs
e4e823cf1b3c26de297ba60d1607f1f4ff572f489b353b226b569131c9bb915a ./src/models.rs
b3f95c0fc9dfb2ef6d3bb75f5b61c434ed004cfa7fee2c8a93ead194afde5a27 ./src/models.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
b76543136fb9c9742657a9b047629b7382d0e5d73ab468609c2be643e0681d25 ./src/protocol/gree.rs
37bd46ac7c50d1674463eddcf2ee9c85cdbf8fe0829cef13a3f5fe857d3d0f55 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
8577a2cf72a5751cad285a29c824b182d29b4793d4171ed94916a6e829c60b43 ./web/app.js
e82ff244991b968a3dd69f117ce9feeeee6e21c00a257d1d42273283b2d7ff99 ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
4720c9abd96159ae9a6757ede9a6c5e0a2ccb4d7e37f8c2a8c16e814d3f0818a ./web/index.html
3a7b734e25955f148c5ef0518f6b4abbb04c0a1e660cd58270292fba3bee149f ./web/index.html
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
ff9b02c818474b15fbcd67105117b4ab7b21a29bca28141c9f162296324d3f70 ./web/styles.css
e1cddac26b3a2ac6142c90661a3085d88079330aa2bacd6b23ff5164620efcdf ./web/sw.js
2d20ba68af2b67b1c57104f0fbb67a0144e2f6aeb12ed8fc9b616a32baa89a3e ./web/styles.css
f75381d719eb299730a85406eb86a58f56fd2ec9bd566bcb3cb84ae6a2d38f87 ./web/sw.js
+6 -5
View File
@@ -4,7 +4,7 @@
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.5.3**.
Current version: **0.5.4**.
## Highlights
@@ -12,6 +12,7 @@ Current version: **0.5.3**.
- V1 AES-128-ECB and V2 AES-128-GCM discovery/bind/status/command transport,
- automatic ECB/GCM detection from discovery responses and bind fallback,
- power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light,
- capability discovery and control for optional GREE X-FAN, Air, Health and native Sleep features,
- SQLite state/history/event storage,
- automatic SQLite history compaction plus optional InfluxDB 1.x/2.x long-term archive,
- smart thermostat zones with global house Heat/Cool/Off mode and per-zone overrides,
@@ -27,15 +28,15 @@ Current version: **0.5.3**.
- responsive PWA optimized for phones,
- dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes,
- Node-RED-style live automation flow board with sensor → thermostat → decision → unit command nodes,
- configurable night mode with quiet hours, maximum fan speed and optional GREE Quiet requests,
- configurable event-log retention and Home Assistant sensor aliases used by history charts/selectors,
- configurable night mode with quiet hours, maximum fan speed and optional GREE Quiet/native Sleep requests,
- configurable event-log retention and presentation-only Home Assistant sensor aliases used by history charts/selectors,
- settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads,
- JSON-based UI localization loaded from embedded `lang/*.json` language packs,
- light, dark and system appearance modes stored in a browser cookie,
- optional Bearer-token authentication,
- simulator mode for development without physical hardware,
- Debian/Ubuntu LXC systemd installer,
- Home Assistant custom integration that proxies `climate` commands and exposes zone plan sensors, writable zone target-temperature numbers and zone enable switches,
- Home Assistant custom integration that proxies device `climate` commands, exposes full zone `climate` entities with selectable HVAC mode, zone plan sensors, writable target-temperature numbers and enable switches,
- 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.
@@ -45,7 +46,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
On Debian, Ubuntu or an LXC container:
```bash
unzip gree-controller-v0.5.3.zip
unzip gree-controller-v0.5.4.zip
cd gree-controller
chmod +x scripts/*.sh
./scripts/dev.sh
+9 -5
View File
@@ -51,11 +51,15 @@ curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json
"swing_vertical": true,
"quiet": false,
"turbo": false,
"light": true
"light": true,
"xfan": false,
"air": false,
"health": false,
"sleep": false
}
```
Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Physical GREE Celsius setpoints are normalized to whole degrees in the `8..30°C` range.
Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Optional properties map to GREE `Blo` (X-FAN), `Air`, `Health` and `SwhSlp` (Sleep) and should only be used when the device reports the matching `supports_*` capability. Physical GREE Celsius setpoints are normalized to whole degrees in the `8..30°C` range.
## Smart thermostat / house control
@@ -163,7 +167,7 @@ This setting is opt-in and applies only to the controller's outbound Home Assist
## Current control plan
`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode/strategy, each zone's active mode/preset/current and target temperatures, current schedule, manual override expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events.
`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode/strategy, each zone's effective `mode`, `configured_mode`, `inherit_house_mode`, preset/current and target temperatures, current schedule, manual override expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events.
```bash
curl "$BASE/api/control-plan" -H "$AUTH"
@@ -235,6 +239,6 @@ Runtime settings support either InfluxDB 1.x (`version=1`, URL, database and opt
## Night mode and sensor aliases
Runtime settings include `night_mode` with `enabled`, `start_time`, `end_time`, `max_fan_speed` (1-5) and `force_quiet`. Overnight windows crossing midnight are supported. During the window thermostat-generated fan commands are limited to the configured maximum; Auto is converted to a bounded low speed, and `Quiet` is requested only when the unit supports it.
Runtime settings include `night_mode` with `enabled`, `start_time`, `end_time`, `max_fan_speed` (1-5), `force_quiet` and `use_native_sleep`. Overnight windows crossing midnight are supported. During the window thermostat-generated fan commands are limited to the configured maximum; Auto is converted to a bounded low speed, while `Quiet` and native `Sleep` are requested only when the unit supports them.
`home_assistant.sensor_aliases` is a map of Home Assistant `entity_id` to a friendly UI name, for example `{"sensor.gabinet_temperature":"Gabinet"}`. Aliases affect labels in the controller UI/history only; metric storage and Home Assistant API requests continue to use the original entity ID.
`home_assistant.sensor_aliases` is a map of Home Assistant `entity_id` to a friendly UI name, for example `{"sensor.gabinet_temperature":"Gabinet"}`. Aliases affect labels in the controller UI/history only. The backend canonicalizes aliases back to their original keys before Home Assistant requests and metric storage; an alias accidentally saved in a sensor field is also resolved defensively.
+6
View File
@@ -118,3 +118,9 @@ 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.
## Zone climate and optional unit features (0.5.4)
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. The existing zone target `number` and enabled `switch` remain available for compatibility.
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
@@ -47,7 +47,7 @@ async def async_setup_entry(
"""Create climate entities for all devices exposed by the controller."""
runtime: GreeControllerRuntimeData = entry.runtime_data
registry = er.async_get(hass)
entities: list[GreeControllerClimate] = []
entities: list[ClimateEntity] = []
for device_id, device in runtime.coordinator.data.items():
desired_entity_id = runtime.entity_map.get(device_id)
@@ -70,6 +70,11 @@ async def async_setup_entry(
GreeControllerClimate(runtime.coordinator, device_id, desired_entity_id)
)
for zone in runtime.coordinator.plan.get("zones", []):
zone_id = str(zone.get("zone_id") or "").strip()
if zone_id:
entities.append(GreeControllerZoneClimate(runtime.coordinator, zone_id))
async_add_entities(entities)
@@ -166,6 +171,10 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
"quiet": bool(device.get("quiet", False)),
"turbo": bool(device.get("turbo", False)),
"light": bool(device.get("light", False)),
"xfan": bool(device.get("xfan", False)),
"air": bool(device.get("air", False)),
"health": bool(device.get("health", False)),
"sleep": bool(device.get("sleep", False)),
"last_seen": device.get("last_seen"),
}
@@ -202,3 +211,109 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
await self._command({"swing_horizontal": swing_horizontal_mode == SWING_ON})
class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
"""Full climate entity for a controller thermostat zone."""
_attr_has_entity_name = True
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = 8.0
_attr_max_temp = 30.0
_attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-zone-climate"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def current_temperature(self) -> float | None:
value = self._zone.get("current_temperature")
return float(value) if value is not None else None
@property
def target_temperature(self) -> float | None:
value = self._zone.get("target_temperature")
return float(value) if value is not None else None
@property
def hvac_mode(self) -> HVACMode:
zone = self._zone
if not zone.get("enabled", False):
return HVACMode.OFF
if zone.get("inherit_house_mode", True):
return HVACMode.AUTO
return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
return {
"controller_zone_id": self._zone_id,
"effective_mode": zone.get("mode"),
"follows_house_mode": bool(zone.get("inherit_house_mode", True)),
"preset": zone.get("preset"),
"demand": bool(zone.get("demand", False)),
"device_setpoint": zone.get("device_setpoint"),
"control_source": zone.get("control_source"),
"current_schedule": zone.get("current_schedule_name"),
}
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
payload = {"enabled": False}
elif hvac_mode == HVACMode.AUTO:
payload = {"enabled": True, "mode": "house"}
elif hvac_mode == HVACMode.COOL:
payload = {"enabled": True, "mode": "cool"}
elif hvac_mode == HVACMode.HEAT:
payload = {"enabled": True, "mode": "heat"}
else:
return
await self.coordinator.client.zone_control(self._zone_id, payload)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(temperature)})
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.5.3",
"version": "0.5.4",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -16,6 +16,17 @@ from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
DEVICE_FEATURE_SWITCHES = [
("light", "supports_light", "Panel light"),
("quiet", "supports_quiet", "Quiet"),
("turbo", "supports_turbo", "Turbo"),
("xfan", "supports_xfan", "X-FAN"),
("air", "supports_air", "Air"),
("health", "supports_health", "Health"),
("sleep", "supports_sleep", "Sleep"),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -23,11 +34,15 @@ async def async_setup_entry(
) -> None:
"""Create one enabled switch per controller zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities = [
entities: list[SwitchEntity] = [
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id")
]
for device_id, device in runtime.coordinator.data.items():
for field, support_field, name in DEVICE_FEATURE_SWITCHES:
if device.get(support_field) is True:
entities.append(GreeControllerDeviceFeatureSwitch(runtime.coordinator, device_id, field, support_field, name))
async_add_entities(entities)
@@ -75,3 +90,61 @@ class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinato
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Optional GREE unit feature exposed only when the controller detected it."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: GreeControllerCoordinator,
device_id: str,
field: str,
support_field: str,
name: str,
) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._field = field
self._support_field = support_field
self._attr_name = name
self._attr_unique_id = f"{device_id}-{field}"
@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))
and self._device.get(self._support_field) is True
)
@property
def is_on(self) -> bool:
return bool(self._device.get(self._field, 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,
)
async def _set(self, value: bool) -> None:
await self.coordinator.client.command(self._device_id, {self._field: value})
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
await self._set(True)
async def async_turn_off(self, **kwargs: Any) -> None:
await self._set(False)
+11 -3
View File
@@ -448,7 +448,7 @@
"history.range": "Range",
"settings.controllerHint": "Runtime and LAN discovery settings.",
"settings.nightMode": "Night mode",
"settings.nightModeHint": "During selected hours the thermostat limits fan speed and can request Quiet on supported units.",
"settings.nightModeHint": "During selected hours the thermostat limits fan speed and can request Quiet plus native Sleep on compatible units.",
"settings.nightModeEnabled": "Enable night mode",
"settings.nightStart": "Start",
"settings.nightEnd": "End",
@@ -458,7 +458,7 @@
"settings.eventRetentionDays": "Event/log retention (days)",
"settings.debugHint": "Live diagnostics displayed on every application page.",
"settings.sensorAliases": "Sensor aliases",
"settings.sensorAliasesHint": "Friendly names are used in charts and selectors; the original entity_id remains unchanged.",
"settings.sensorAliasesHint": "Friendly names are used only in charts and selectors. Home Assistant requests always use the original entity_id.",
"settings.aliasPlaceholder": "Friendly name",
"settings.noSensorAliases": "No Home Assistant sensors are known yet. Add an entity_id below.",
"settings.aliasRequired": "Enter both entity_id and alias.",
@@ -482,6 +482,14 @@
"simulation.schedule": "Schedule",
"common.days": "days",
"actions.clear": "Clear",
"simulation.scheduled": "Scheduled"
"simulation.scheduled": "Scheduled",
"settings.nightNativeSleep": "Use native Sleep when supported",
"devices.features": "Unit features",
"devices.noExtraFeatures": "No additional features detected yet.",
"devices.light": "Panel light",
"devices.xfan": "X-FAN",
"devices.health": "Health",
"devices.air": "Air",
"devices.sleep": "Sleep"
}
}
+11 -3
View File
@@ -448,7 +448,7 @@
"history.range": "Zakres",
"settings.controllerHint": "Ustawienia pracy kontrolera i wykrywania urządzeń w LAN.",
"settings.nightMode": "Tryb nocny",
"settings.nightModeHint": "W wybranych godzinach termostat ogranicza obroty wentylatora i może włączać Quiet w obsługiwanych jednostkach.",
"settings.nightModeHint": "W wybranych godzinach termostat ogranicza obroty wentylatora i może włączać Quiet oraz natywny Sleep na zgodnych jednostkach.",
"settings.nightModeEnabled": "Włącz tryb nocny",
"settings.nightStart": "Początek",
"settings.nightEnd": "Koniec",
@@ -458,7 +458,7 @@
"settings.eventRetentionDays": "Retencja zdarzeń/logów (dni)",
"settings.debugHint": "Diagnostyka na żywo wyświetlana na każdej podstronie aplikacji.",
"settings.sensorAliases": "Aliasy czujników",
"settings.sensorAliasesHint": "Przyjazne nazwy są używane na wykresach i w wyborach; oryginalny entity_id pozostaje bez zmian.",
"settings.sensorAliasesHint": "Przyjazne nazwy są używane tylko na wykresach i w selektorach. Zapytania do Home Assistanta zawsze używają oryginalnego entity_id.",
"settings.aliasPlaceholder": "Przyjazna nazwa",
"settings.noSensorAliases": "Nie wykryto jeszcze czujników Home Assistant. Możesz dodać entity_id poniżej.",
"settings.aliasRequired": "Podaj entity_id oraz alias.",
@@ -482,6 +482,14 @@
"simulation.schedule": "Harmonogram",
"common.days": "dni",
"actions.clear": "Wyczyść",
"simulation.scheduled": "Zaplanowany"
"simulation.scheduled": "Zaplanowany",
"settings.nightNativeSleep": "Używaj natywnego Sleep, jeśli jednostka go obsługuje",
"devices.features": "Funkcje jednostki",
"devices.noExtraFeatures": "Nie wykryto jeszcze dodatkowych funkcji.",
"devices.light": "Podświetlenie panelu",
"devices.xfan": "X-FAN",
"devices.health": "Health",
"devices.air": "Air",
"devices.sleep": "Sleep"
}
}
+68 -4
View File
@@ -283,6 +283,17 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: None,
supports_quiet: None,
supports_turbo: None,
supports_air: None,
supports_xfan: None,
supports_health: None,
supports_sleep: None,
current_temperature: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -308,7 +319,20 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
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 { let v = v.min(2); if device.protocol_version != v { device.protocol_version = v; device.key = None; } }
if let Some(v) = patch.protocol_version {
let v = v.min(2);
if device.protocol_version != v {
device.protocol_version = v;
device.key = None;
device.supports_light = None;
device.supports_quiet = None;
device.supports_turbo = None;
device.supports_air = None;
device.supports_xfan = None;
device.supports_health = None;
device.supports_sleep = None;
}
}
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();
@@ -455,7 +479,9 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
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());
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
state.db.save_zone(&zone)?;
state.broadcast("zone.created", serde_json::to_value(&zone)?);
Ok((StatusCode::CREATED, Json(zone)))
@@ -478,6 +504,8 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.device_setpoint = existing.device_setpoint;
zone.demand = existing.demand;
zone.last_action_at = existing.last_action_at;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(Json(zone))
@@ -1104,6 +1132,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
normalize_sensor_aliases(&mut input);
canonicalize_home_assistant_entities(&mut input);
validate_night_mode(&mut input)?;
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
@@ -1114,6 +1143,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
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)?;
canonicalize_saved_zone_entities(&state, &input)?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
@@ -1133,6 +1163,37 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
.collect();
}
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
let default_entity = settings.home_assistant.default_entity_id.clone();
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
settings.home_assistant.default_entity_id = entity_id;
}
let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone();
if !outdoor_entity.trim().is_empty() {
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) {
settings.home_assistant.outdoor_entity_id = entity_id;
}
}
}
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
let Some(configured) = zone.ha_entity_id.clone() else { return; };
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
}
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
for mut zone in state.db.list_zones()? {
let previous = zone.ha_entity_id.clone();
canonicalize_zone_ha_entity(&mut zone, settings);
if zone.ha_entity_id != previous {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
}
Ok(())
}
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
@@ -1172,6 +1233,8 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
normalize_sensor_aliases(&mut export.settings);
canonicalize_home_assistant_entities(&mut export.settings);
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
validate_night_mode(&mut export.settings)?;
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
state.db.replace_configuration(&export)?;
@@ -1247,9 +1310,10 @@ async fn delete_access_token(State(state): State<AppState>, Path(id): Path<Strin
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())
let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref())
.await.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(json!({"ok": true, "temperature_c": temperature})))
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
}
fn public_settings(settings: &RuntimeSettings) -> Value {
+2
View File
@@ -67,6 +67,7 @@ impl Config {
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()),
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5),
force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true),
use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP").unwrap_or(true),
},
home_assistant: HomeAssistantSettings {
url: env::var("HA_URL").unwrap_or_default(),
@@ -98,6 +99,7 @@ impl Config {
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } }
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; }
let influx_env_present = [
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
+85 -15
View File
@@ -154,6 +154,8 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
}
}
}
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); }
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); }
applied_command.apply(&mut device);
device.online = true;
device.communication_failures = 0;
@@ -309,18 +311,22 @@ async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
// it only makes the active setpoint/fan a little more assertive in extreme weather.
let outdoor_temperature = if !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(settings.home_assistant.outdoor_entity_id.trim()),
).await {
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
let device_snapshot = state.db.list_devices()?;
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
let resolved_outdoor = if configured_outdoor.is_empty() {
None
} else {
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
};
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id)).await {
Ok(value) => {
record_ha_history(
state,
settings.home_assistant.outdoor_entity_id.trim(),
entity_id,
None,
"outdoor",
value,
@@ -329,13 +335,14 @@ async fn control_zones(state: &AppState) -> Result<()> {
Some(value)
}
Err(err) => {
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
None
}
}
} else {
None
};
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
{
let mut current = state.outdoor_temperature.write().await;
if *current != outdoor_temperature {
@@ -372,16 +379,21 @@ async fn control_zones(state: &AppState) -> Result<()> {
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 {
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
match home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity.as_deref()).await {
Ok(value) => {
if let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) {
if let Some(entity_id) = resolved_entity.as_deref() {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
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()}));
state.log("warn", "ha.sensor_error", &err.to_string(), json!({
"zone_id": zone.id,
"configured_entity_id": zone.ha_entity_id.as_deref(),
"resolved_entity_id": resolved_entity,
}));
}
None
}
@@ -497,12 +509,20 @@ async fn control_zones(state: &AppState) -> Result<()> {
night_active,
settings.night_mode.force_quiet,
);
let desired_sleep = native_sleep_command(
settings.night_mode.enabled,
night_active,
settings.night_mode.use_native_sleep,
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
device.sleep,
);
let needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false)
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false);
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
let urgent_mode_change = !device.power || device.mode != effective_mode;
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
@@ -512,6 +532,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
target_temperature: Some(desired_device_target),
fan_speed: desired_fan,
quiet: desired_quiet,
sleep: desired_sleep,
..Default::default()
};
match send_command(state, &zone.device_id, command).await {
@@ -527,6 +548,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
"outdoor_temperature": outdoor_temperature,
"fan_speed": updated_device.fan_speed,
"quiet": updated_device.quiet,
"sleep": updated_device.sleep,
"night_mode": night_active,
}));
}
@@ -633,6 +655,23 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
});
}
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
let mut values: Vec<f64> = devices.iter()
.filter(|device| device.enabled && device.online)
.filter_map(|device| device.outdoor_temperature)
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
.collect();
if values.is_empty() { return None; }
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = values.len() / 2;
let value = if values.len() % 2 == 0 {
(values[middle - 1] + values[middle]) / 2.0
} else {
values[middle]
};
Some((value * 10.0).round() / 10.0)
}
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) {
@@ -707,6 +746,19 @@ fn smart_quiet_command(
None
}
fn native_sleep_command(
night_enabled: bool,
night_active: bool,
use_native_sleep: bool,
sleep_supported: bool,
device_sleep: bool,
) -> Option<bool> {
if !night_enabled || !use_native_sleep || !sleep_supported { return None; }
if night_active { return Some(true); }
if device_sleep { return Some(false); }
None
}
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
let max_fan = max_fan.clamp(1, 5);
if requested == 0 { 1 } else { requested.min(max_fan) }
@@ -854,6 +906,8 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled,
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
@@ -1156,7 +1210,7 @@ mod tests {
#[test]
fn night_mode_handles_midnight_and_limits_auto_fan() {
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true };
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true };
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
@@ -1164,6 +1218,22 @@ mod tests {
assert_eq!(night_limited_fan_speed(3, 1), 1);
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, true, false), Some(true));
assert_eq!(native_sleep_command(true, false, true, true, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, false, false), None);
}
#[test]
fn gree_outdoor_fallback_uses_median_of_online_units() {
let mut a = Device::simulated_default();
a.outdoor_temperature = Some(10.0);
let mut b = Device::simulated_default();
b.id = "sim-b".into();
b.outdoor_temperature = Some(12.0);
let mut c = Device::simulated_default();
c.id = "sim-c".into();
c.outdoor_temperature = Some(40.0);
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
}
#[test]
+48 -3
View File
@@ -17,6 +17,25 @@ fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSett
.context("cannot build Home Assistant HTTPS client")
}
pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Option<&str>) -> Option<String> {
let requested = entity_override.filter(|value| !value.trim().is_empty())
.map(str::trim)
.unwrap_or_else(|| settings.default_entity_id.trim());
if requested.is_empty() { return None; }
// Aliases are presentation-only. Accepting an alias here is a defensive
// compatibility path for settings saved by older UI revisions or manual edits;
// the actual Home Assistant request always uses the original entity_id key.
if settings.sensor_aliases.contains_key(requested) {
return Some(requested.to_string());
}
if let Some((entity_id, _)) = settings.sensor_aliases.iter()
.find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested)) {
return Some(entity_id.clone());
}
Some(requested.to_string())
}
pub async fn read_temperature(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
@@ -24,9 +43,8 @@ pub async fn read_temperature(
) -> 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 entity = resolve_entity_id(settings, entity_override)
.ok_or_else(|| anyhow!("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") }
@@ -53,3 +71,30 @@ pub async fn read_temperature(
}
Ok((temperature * 10.0).round() / 10.0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn settings() -> HomeAssistantSettings {
let mut sensor_aliases = BTreeMap::new();
sensor_aliases.insert("sensor.gabinet_temperature".into(), "Gabinet".into());
HomeAssistantSettings {
url: "http://homeassistant.local:8123".into(),
token: "token".into(),
default_entity_id: "sensor.salon_temperature".into(),
outdoor_entity_id: "sensor.zewnatrz_temperature".into(),
allow_invalid_tls: false,
sensor_aliases,
}
}
#[test]
fn aliases_never_replace_real_home_assistant_entity_ids() {
let settings = settings();
assert_eq!(resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(), Some("sensor.gabinet_temperature"));
assert_eq!(resolve_entity_id(&settings, Some("Gabinet")).as_deref(), Some("sensor.gabinet_temperature"));
assert_eq!(resolve_entity_id(&settings, None).as_deref(), Some("sensor.salon_temperature"));
}
}
+55
View File
@@ -76,6 +76,29 @@ pub struct Device {
pub turbo: bool,
#[serde(default)]
pub light: bool,
/// Optional GREE feature states. Support is learned from status responses.
#[serde(default)]
pub air: bool,
#[serde(default)]
pub xfan: bool,
#[serde(default)]
pub health: bool,
#[serde(default)]
pub sleep: bool,
#[serde(default)]
pub supports_light: Option<bool>,
#[serde(default)]
pub supports_quiet: Option<bool>,
#[serde(default)]
pub supports_turbo: Option<bool>,
#[serde(default)]
pub supports_air: Option<bool>,
#[serde(default)]
pub supports_xfan: Option<bool>,
#[serde(default)]
pub supports_health: Option<bool>,
#[serde(default)]
pub supports_sleep: Option<bool>,
#[serde(default)]
pub current_temperature: Option<f64>,
#[serde(default)]
@@ -120,6 +143,17 @@ impl Device {
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: Some(true),
supports_quiet: Some(true),
supports_turbo: Some(true),
supports_air: Some(true),
supports_xfan: Some(true),
supports_health: Some(true),
supports_sleep: Some(true),
current_temperature: Some(26.0),
outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false),
@@ -154,6 +188,10 @@ pub struct DeviceCommand {
pub quiet: Option<bool>,
pub turbo: Option<bool>,
pub light: Option<bool>,
pub air: Option<bool>,
pub xfan: Option<bool>,
pub health: Option<bool>,
pub sleep: Option<bool>,
}
impl DeviceCommand {
@@ -161,6 +199,7 @@ impl DeviceCommand {
self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none()
&& self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none()
&& self.quiet.is_none() && self.turbo.is_none() && self.light.is_none()
&& self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.is_none()
}
/// Return only fields that differ from the last known device state.
@@ -175,6 +214,10 @@ impl DeviceCommand {
quiet: self.quiet.filter(|value| *value != device.quiet),
turbo: self.turbo.filter(|value| *value != device.turbo),
light: self.light.filter(|value| *value != device.light),
air: self.air.filter(|value| *value != device.air),
xfan: self.xfan.filter(|value| *value != device.xfan),
health: self.health.filter(|value| *value != device.health),
sleep: self.sleep.filter(|value| *value != device.sleep),
}
}
@@ -188,6 +231,10 @@ impl DeviceCommand {
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; }
if let Some(v) = self.air { device.air = v; }
if let Some(v) = self.xfan { device.xfan = v; }
if let Some(v) = self.health { device.health = v; }
if let Some(v) = self.sleep { device.sleep = v; }
device.updated_at = Utc::now();
}
}
@@ -487,6 +534,9 @@ pub struct NightModeSettings {
/// Ask compatible GREE units to keep Quiet enabled during the whole night window.
#[serde(default = "default_true")]
pub force_quiet: bool,
/// Use the unit's native Sleep function during the night window when it is supported.
#[serde(default = "default_true")]
pub use_native_sleep: bool,
}
impl Default for NightModeSettings {
@@ -497,6 +547,7 @@ impl Default for NightModeSettings {
end_time: default_night_end(),
max_fan_speed: default_night_max_fan_speed(),
force_quiet: true,
use_native_sleep: true,
}
}
}
@@ -528,7 +579,11 @@ pub struct ZoneControlPlan {
pub device_id: String,
pub device_name: String,
pub enabled: bool,
/// Effective mode currently used by the controller.
pub mode: String,
/// Configured zone mode before house-mode inheritance is resolved.
pub configured_mode: String,
pub inherit_house_mode: bool,
pub preset: String,
pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>,
+120 -20
View File
@@ -24,6 +24,7 @@ pub struct GreeClient {
debug_gree_frames: Arc<AtomicBool>,
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
quiet_unsupported: Arc<Mutex<HashSet<String>>>,
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
}
impl GreeClient {
@@ -40,6 +41,7 @@ impl GreeClient {
debug_gree_frames,
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
}
}
@@ -252,6 +254,17 @@ impl GreeClient {
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: None,
supports_quiet: None,
supports_turbo: None,
supports_air: None,
supports_xfan: None,
supports_health: None,
supports_sleep: None,
current_temperature: None,
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -357,6 +370,10 @@ impl GreeClient {
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
}
}
// Capability discovery is deliberately lazy. Existing installations start with
// unknown support flags and each optional property is probed at most until a
// definitive success/failure has been persisted with the device state.
self.probe_optional_features(device, &key).await;
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
@@ -370,6 +387,49 @@ impl GreeClient {
self.request(device, &inner, key, false, device.protocol_version).await
}
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
let probes = [
("Lig", device.supports_light.is_none()),
("Quiet", device.supports_quiet.is_none()),
("Tur", device.supports_turbo.is_none()),
("Air", device.supports_air.is_none()),
("Blo", device.supports_xfan.is_none()),
("Health", device.supports_health.is_none()),
("SwhSlp", device.supports_sleep.is_none()),
];
for (property, needed) in probes {
if !needed { continue; }
match self.status_request(device, key, &[property]).await {
Ok(value) => {
let returned = value.get("cols").and_then(Value::as_array)
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
.unwrap_or(false);
if !returned || self.apply_status(device, &value).is_err() {
Self::set_feature_support(device, property, false);
}
}
Err(err) => {
Self::set_feature_support(device, property, false);
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
}
}
}
}
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
let value = Some(supported);
match property {
"Lig" => device.supports_light = value,
"Quiet" => device.supports_quiet = value,
"Tur" => device.supports_turbo = value,
"Air" => device.supports_air = value,
"Blo" => device.supports_xfan = value,
"Health" => device.supports_health = value,
"SwhSlp" => device.supports_sleep = value,
_ => {}
}
}
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
let response_cols = response.get("cols").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?;
@@ -385,9 +445,13 @@ impl GreeClient {
"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,
"Quiet" => { device.quiet = value_as_i64(value) != 0; device.supports_quiet = Some(true); },
"Tur" => { device.turbo = value_as_i64(value) != 0; device.supports_turbo = Some(true); },
"Lig" => { device.light = value_as_i64(value) != 0; device.supports_light = Some(true); },
"Air" => { device.air = value_as_i64(value) != 0; device.supports_air = Some(true); },
"Blo" => { device.xfan = value_as_i64(value) != 0; device.supports_xfan = Some(true); },
"Health" => { device.health = value_as_i64(value) != 0; device.supports_health = Some(true); },
"SwhSlp" => { device.sleep = value_as_i64(value) != 0; device.supports_sleep = Some(true); },
"TemSen" => {
let raw = value_as_f64(value);
// The room sensor is a useful discriminator because normal indoor
@@ -420,6 +484,10 @@ impl GreeClient {
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
async fn request_command_with_buzzer_fallback(
&self,
device: &Device,
@@ -455,29 +523,56 @@ impl GreeClient {
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
effective.quiet = None;
}
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
effective.sleep = None;
}
if effective.is_empty() {
return Ok(effective);
}
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
Ok(_) => Ok(effective),
Err(first_err) if effective.quiet.is_some() => {
// Quiet is not implemented by every GREE indoor unit. If a combined thermostat
// command is rejected, retry the same setpoint/fan change without Quiet. Once
// that succeeds, remember the unit so future thermostat frames omit Quiet.
let mut fallback = effective.clone();
fallback.quiet = None;
if fallback.is_empty() { return Err(first_err); }
match self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await {
Ok(_) => {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will use Low fan without Quiet for this device");
Ok(fallback)
Err(first_err) => {
// Quiet and native Sleep are optional GREE features. A unit may report a
// broader status schema than it accepts in command frames, so preserve
// the actual thermostat change and retry without the optional property.
if effective.sleep.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
return Ok(fallback);
}
}
Err(_) => Err(first_err),
}
if effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
return Ok(fallback);
}
}
}
if effective.sleep.is_some() && effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
return Ok(fallback);
}
}
}
Err(first_err)
}
Err(err) => Err(err),
}
}
@@ -498,6 +593,10 @@ impl GreeClient {
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 let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
if suppress_beep {
opt.push("Buzzer_ON_OFF"); values.push(json!(1));
@@ -731,15 +830,16 @@ mod tests {
use super::*;
#[test]
fn thermostat_standby_setpoint_low_fan_and_quiet_share_one_frame() {
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand {
target_temperature: Some(19.0),
fan_speed: Some(1),
quiet: Some(true),
sleep: Some(true),
..DeviceCommand::default()
}, false).expect("thermostat command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1])));
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
}
}
+21 -4
View File
@@ -369,6 +369,7 @@ function buildZoneSimulation(zone, planZone) {
const quiet = nightActive && app.settings?.night_mode?.force_quiet
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZone?.enabled === false
? tr('simulation.reasonDisabled')
: mode === 'off'
@@ -378,7 +379,7 @@ function buildZoneSimulation(zone, planZone) {
: demand
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, reasoning};
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning};
}
function simulationRuleAction(rule) {
@@ -450,7 +451,8 @@ function renderSimulationPage() {
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:modeLabel(sim.mode)}));
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}`,badge:sim.demand?'RUN':'IDLE',badgeClass:sim.demand?'active':''}));
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.demand?'RUN':'IDLE',badgeClass:sim.demand?'active':''}));
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:''}));
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
@@ -488,6 +490,18 @@ function scheduleControlPlanLoad() {
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
}
function deviceFeaturePanel(device) {
const features = [
['light', 'supports_light', tr('devices.light')],
['xfan', 'supports_xfan', tr('devices.xfan')],
['health', 'supports_health', tr('devices.health')],
['air', 'supports_air', tr('devices.air')],
['sleep', 'supports_sleep', tr('devices.sleep')],
].filter(([, support]) => device[support] === true);
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field,,label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
@@ -509,9 +523,10 @@ function deviceCard(device, detailed = false) {
<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>
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div>
${detailed ? deviceFeaturePanel(device) : ''}
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</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>`;
}
@@ -663,6 +678,7 @@ function renderSettings() {
form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00';
form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1);
form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false;
form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false;
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
form.influx_version.value = String(app.settings.influxdb?.version || '2');
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
@@ -1460,6 +1476,7 @@ function settingsBodyFromForm(form) {
night_mode:{
enabled:form.night_mode_enabled.checked,start_time:raw.night_mode_start,end_time:raw.night_mode_end,
max_fan_speed:Number(raw.night_mode_max_fan_speed),force_quiet:form.night_mode_force_quiet.checked,
use_native_sleep:form.night_mode_native_sleep.checked,
},
influxdb:{
enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url,
+10 -9
View File
@@ -139,7 +139,7 @@
<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="settings-form" id="settingsForm">
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.controller">Controller</h3><p data-i18n="settings.controllerHint">Runtime and LAN discovery settings.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.controller">Controller</h3><p data-i18n="settings.controllerHint">Runtime and LAN discovery settings.</p></div></div>
<div class="settings-grid">
<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>
@@ -151,18 +151,19 @@
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.nightMode">Night mode</h3><p data-i18n="settings.nightModeHint">During selected hours the thermostat limits fan speed and can request Quiet on supported units.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.nightMode">Night mode</h3><p data-i18n="settings.nightModeHint">During selected hours the thermostat limits fan speed and can request Quiet on supported units.</p></div></div>
<div class="settings-grid">
<label class="check wide"><input type="checkbox" name="night_mode_enabled"> <span data-i18n="settings.nightModeEnabled">Enable night mode</span></label>
<label><span data-i18n="settings.nightStart">Start</span><input type="time" name="night_mode_start" value="22:00"></label>
<label><span data-i18n="settings.nightEnd">End</span><input type="time" name="night_mode_end" value="06:00"></label>
<label><span data-i18n="settings.nightMaxFan">Maximum fan speed</span><select name="night_mode_max_fan_speed"><option value="1" data-i18n="fan.low">Low</option><option value="2" data-i18n="fan.mediumLow">Medium-low</option><option value="3" data-i18n="fan.medium">Medium</option><option value="4" data-i18n="fan.mediumHigh">Medium-high</option><option value="5" data-i18n="fan.high">High</option></select></label>
<label class="check"><input type="checkbox" name="night_mode_force_quiet"> <span data-i18n="settings.nightForceQuiet">Use Quiet when supported</span></label>
<label class="check wide"><input type="checkbox" name="night_mode_native_sleep"> <span data-i18n="settings.nightNativeSleep">Use native Sleep when supported</span></label>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.metricsAndLogs">Metrics and logs</h3><p data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.metricsAndLogs">Metrics and logs</h3><p data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p></div></div>
<div class="settings-grid">
<label><span data-i18n="settings.retentionDays">Local metric retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
<label><span data-i18n="settings.eventRetentionDays">Event/log retention (days)</span><input type="number" name="event_log_retention_days" min="1" max="3650" required></label>
@@ -171,12 +172,12 @@
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.greeCommands">GREE commands</h3><p data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.greeCommands">GREE commands</h3><p data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p></div></div>
<div class="settings-grid"><label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label></div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.influx">Long-term InfluxDB history</h3><p data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.influx">Long-term InfluxDB history</h3><p data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p></div></div>
<div class="settings-grid">
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
@@ -196,7 +197,7 @@
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.debug">On-screen debug</h3><p data-i18n="settings.debugHint">Live diagnostics displayed on every application page.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.debug">On-screen debug</h3><p data-i18n="settings.debugHint">Live diagnostics displayed on every application page.</p></div></div>
<div class="settings-grid">
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
@@ -204,7 +205,7 @@
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon">HA</span><div><h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3><p data-i18n="settings.haSensorInputHint">Optional. Used when a room zone reads an external Home Assistant temperature sensor.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3><p data-i18n="settings.haSensorInputHint">Optional. Used when a room zone reads an external Home Assistant temperature sensor.</p></div></div>
<div class="settings-grid">
<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>
@@ -223,12 +224,12 @@
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div>
<div class="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>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.backup">Configuration backup</h3><p class="warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.backup">Configuration backup</h3><p class="warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p></div></div>
<div class="form-actions backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
</section>
+8 -1
View File
@@ -529,7 +529,6 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.settings-block-head { display:flex; align-items:flex-start; gap:13px; }
.settings-block-head h3 { margin:1px 0 4px; font-size:18px; }
.settings-block-head p { margin:0; color:var(--muted); font-size:12px; line-height:1.5; }
.settings-block-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border:1px solid var(--line); border-radius:11px; color:var(--accent); background:var(--surface-muted); font-size:14px; font-weight:800; }
.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.settings-grid .wide { grid-column:1/-1; }
.settings-subhead { display:flex; justify-content:space-between; gap:12px; margin-bottom:10px; }
@@ -605,3 +604,11 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.logs-toolbar { grid-template-columns:1fr; align-items:stretch; }
.simulation-flow-head { align-items:flex-start; flex-direction:column; }
}
/* v0.5.4: settings headers no longer use decorative icons. */
.settings-block-head { gap:0; }
.device-capability-panel { display:grid; gap:8px; padding:12px 0 2px; border-top:1px solid var(--line); }
.device-capability-panel > small { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; }
.device-capability-buttons { display:flex; flex-wrap:wrap; gap:7px; }
.device-capability-buttons button { min-height:34px; padding:7px 11px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); color:var(--text); }
.device-capability-buttons button.active { border-color:var(--accent); color:var(--accent); }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v053';
const CACHE = 'gree-controller-v054';
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())));