diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 0288ef0..5835244 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 36c9f39..e0eb817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.5.3" +version = "0.5.4" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 052b3bd..763d324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 3eabeff..70dd9f6 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 7801683..5d6ec6b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.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 diff --git a/docs/API.md b/docs/API.md index 0915ac5..6e91453 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/home-assistant/README.md b/home-assistant/README.md index 3069d41..0722e3a 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -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. + diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py index 8d40a2c..97c28e7 100644 --- a/home-assistant/custom_components/gree_controller/climate.py +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -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() + diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index bee9b58..123b3c3 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.5.3", + "version": "0.5.4", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/home-assistant/custom_components/gree_controller/switch.py b/home-assistant/custom_components/gree_controller/switch.py index abafc52..e2bdb10 100644 --- a/home-assistant/custom_components/gree_controller/switch.py +++ b/home-assistant/custom_components/gree_controller/switch.py @@ -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) + diff --git a/lang/en.json b/lang/en.json index 11ee2f2..5aaaf5b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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" } } diff --git a/lang/pl.json b/lang/pl.json index a77c84d..c41ae9a 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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" } } diff --git a/src/api.rs b/src/api.rs index e978700..dc39b86 100644 --- a/src/api.rs +++ b/src/api.rs @@ -283,6 +283,17 @@ async fn add_device(State(state): State, Json(input): Json, Path(id): Path, 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::().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, Path(id): Path) -> Resu async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), 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, Path(id): Path, 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, Json(mut input): Json, Json(mut input): Json 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, Json(mut export): Json, Path(id): Path } async fn test_home_assistant(State(state): State, Json(input): Json) -> Result, 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 { diff --git a/src/config.rs b/src/config.rs index 453b6e7..c8d955f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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", diff --git a/src/engine.rs b/src/engine.rs index 83a7bf0..abf8a92 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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 { + let mut values: Vec = 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, external_temperature: Option) -> (Option, 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 { + 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) -> Option { + 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 { 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")); + } +} diff --git a/src/models.rs b/src/models.rs index 77494df..80d670e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + #[serde(default)] + pub supports_quiet: Option, + #[serde(default)] + pub supports_turbo: Option, + #[serde(default)] + pub supports_air: Option, + #[serde(default)] + pub supports_xfan: Option, + #[serde(default)] + pub supports_health: Option, + #[serde(default)] + pub supports_sleep: Option, #[serde(default)] pub current_temperature: Option, #[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, pub turbo: Option, pub light: Option, + pub air: Option, + pub xfan: Option, + pub health: Option, + pub sleep: Option, } 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, pub target_temperature: Option, diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index 48c922e..809d7e8 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -24,6 +24,7 @@ pub struct GreeClient { debug_gree_frames: Arc, buzzer_unsupported: Arc>>, quiet_unsupported: Arc>>, + sleep_unsupported: Arc>>, } 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]))); } } diff --git a/web/app.js b/web/app.js index 238a002..19b9756 100644 --- a/web/app.js +++ b/web/app.js @@ -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 `
${esc(tr('devices.features'))}${esc(tr('devices.noExtraFeatures'))}
`; + return `
${esc(tr('devices.features'))}
${features.map(([field,,label]) => ``).join('')}
`; +} + 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) {
${fans.map(fan => ``).join('')}
- - + ${device.supports_quiet === false ? '' : ``} + ${device.supports_turbo === false ? '' : ``}
+ ${detailed ? deviceFeaturePanel(device) : ''} ${detailed ? `` : ''} `; } @@ -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, diff --git a/web/index.html b/web/index.html index 9488bfa..3335195 100644 --- a/web/index.html +++ b/web/index.html @@ -139,7 +139,7 @@
System

Settings

-

Controller

Runtime and LAN discovery settings.

+

Controller

Runtime and LAN discovery settings.

@@ -151,18 +151,19 @@
-

Night mode

During selected hours the thermostat limits fan speed and can request Quiet on supported units.

+

Night mode

During selected hours the thermostat limits fan speed and can request Quiet on supported units.

+
-

Metrics and logs

SQLite keeps recent data locally and compacts older samples to the resolution used by charts.

+

Metrics and logs

SQLite keeps recent data locally and compacts older samples to the resolution used by charts.

@@ -171,12 +172,12 @@
-

GREE commands

Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.

+

GREE commands

Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.

-

Long-term InfluxDB history

Optional archive for older history. InfluxDB 1.x and 2.x are supported.

+

Long-term InfluxDB history

Optional archive for older history. InfluxDB 1.x and 2.x are supported.

@@ -196,7 +197,7 @@
-

On-screen debug

Live diagnostics displayed on every application page.

+

On-screen debug

Live diagnostics displayed on every application page.

@@ -204,7 +205,7 @@
-
HA

Home Assistant sensor input

Optional. Used when a room zone reads an external Home Assistant temperature sensor.

+

Home Assistant sensor input

Optional. Used when a room zone reads an external Home Assistant temperature sensor.

@@ -223,12 +224,12 @@
-

Home Assistant integration access

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

+

Home Assistant integration access

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

-

Configuration backup

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.

+

Configuration backup

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.

diff --git a/web/styles.css b/web/styles.css index 28e60ad..b71115e 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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); } diff --git a/web/sw.js b/web/sw.js index f7d9ea3..a17f562 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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())));