v0.7.7
This commit is contained in:
@@ -150,3 +150,7 @@ Every group configured in **GREE Controller -> Groups** is published as a separa
|
||||
If member zones have been changed individually and no longer share one mode or profile, the matching select has no current common value instead of reporting a misleading state. Selecting a group mode/profile applies it to every member through the existing controller group logic.
|
||||
|
||||
After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created.
|
||||
|
||||
## Command state stability (0.7.6)
|
||||
|
||||
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
|
||||
|
||||
@@ -223,8 +223,7 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
|
||||
}
|
||||
|
||||
async def _command(self, payload: dict[str, Any]) -> None:
|
||||
await self.coordinator.client.command(self._device_id, payload)
|
||||
await self.coordinator.async_request_refresh()
|
||||
await self.coordinator.async_device_command(self._device_id, payload)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self._command({"power": True})
|
||||
|
||||
@@ -31,6 +31,9 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
self.client = client
|
||||
self.plan: dict[str, Any] = {}
|
||||
self.groups: dict[str, dict[str, Any]] = {}
|
||||
# Protect a just-accepted device command from an overlapping/stale poll.
|
||||
# GREE units can expose their previous status briefly after acknowledging a write.
|
||||
self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict]:
|
||||
try:
|
||||
@@ -43,4 +46,70 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
raise UpdateFailed(str(err)) from err
|
||||
self.plan = plan
|
||||
self.groups = {str(group["id"]): group for group in groups if group.get("id")}
|
||||
return {str(device["id"]): device for device in devices if device.get("id")}
|
||||
device_map = {str(device["id"]): device for device in devices if device.get("id")}
|
||||
self._overlay_pending_device_commands(device_map)
|
||||
return device_map
|
||||
|
||||
@staticmethod
|
||||
def _normalized_device_command(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize writable fields exactly as the standalone controller does."""
|
||||
allowed = {
|
||||
"power", "mode", "target_temperature", "fan_speed",
|
||||
"swing_vertical", "swing_horizontal", "quiet", "turbo",
|
||||
"light", "air", "xfan", "health", "sleep",
|
||||
}
|
||||
expected = {key: value for key, value in payload.items() if key in allowed}
|
||||
if "target_temperature" in expected:
|
||||
value = max(8.0, min(30.0, float(expected["target_temperature"])))
|
||||
expected["target_temperature"] = float(int(value + 0.5))
|
||||
if "fan_speed" in expected:
|
||||
expected["fan_speed"] = min(5, max(0, int(expected["fan_speed"])))
|
||||
return expected
|
||||
|
||||
def _overlay_pending_device_commands(self, devices: dict[str, dict[str, Any]]) -> None:
|
||||
"""Do not let a stale post-command poll make HA controls bounce backwards."""
|
||||
now = asyncio.get_running_loop().time()
|
||||
for device_id, (deadline, expected) in list(self._pending_device_commands.items()):
|
||||
if now >= deadline:
|
||||
self._pending_device_commands.pop(device_id, None)
|
||||
continue
|
||||
device = devices.get(device_id)
|
||||
if device is not None:
|
||||
device.update(expected)
|
||||
|
||||
async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Send a physical-unit command while keeping HA state monotonic during settling."""
|
||||
expected = self._normalized_device_command(payload)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Register intent before network I/O. A coordinator refresh that was already in flight
|
||||
# when the user clicked must not be allowed to publish the pre-command state.
|
||||
self._pending_device_commands[device_id] = (loop.time() + 5.0, expected)
|
||||
current = dict(self.data or {})
|
||||
if device_id in current:
|
||||
optimistic = dict(current[device_id])
|
||||
optimistic.update(expected)
|
||||
current[device_id] = optimistic
|
||||
self.async_set_updated_data(current)
|
||||
|
||||
try:
|
||||
device = await self.client.command(device_id, payload)
|
||||
except GreeControllerApiError:
|
||||
# Never mask an actual rejected/failed command. Drop the optimistic guard and
|
||||
# immediately restore the latest factual controller state.
|
||||
self._pending_device_commands.pop(device_id, None)
|
||||
await self.async_request_refresh()
|
||||
raise
|
||||
|
||||
# Keep the guard briefly after a successful ACK so delayed firmware status and an
|
||||
# overlapping coordinator refresh cannot roll the entity backwards.
|
||||
self._pending_device_commands[device_id] = (loop.time() + 3.0, expected)
|
||||
current = dict(self.data or {})
|
||||
optimistic = dict(device)
|
||||
optimistic.update(expected)
|
||||
current[device_id] = optimistic
|
||||
self.async_set_updated_data(current)
|
||||
|
||||
# Refresh plan/group metadata immediately as before. Any stale device snapshot in
|
||||
# this refresh is overlaid by the short pending-command guard above.
|
||||
await self.async_request_refresh()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.7",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -225,8 +225,7 @@ class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordina
|
||||
)
|
||||
|
||||
async def _set(self, value: bool) -> None:
|
||||
await self.coordinator.client.command(self._device_id, {self._field: value})
|
||||
await self.coordinator.async_request_refresh()
|
||||
await self.coordinator.async_device_command(self._device_id, {self._field: value})
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self._set(True)
|
||||
|
||||
Reference in New Issue
Block a user