From 35a922eedf48e4a64c7cfaa5ee0e7aa242d54a7d Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Sat, 1 Aug 2026 19:26:35 +0800 Subject: [PATCH 1/3] Bump zhong-hong-hvac to 1.0.15 and add gateway health availability to zhong_hong The gateway connection in zhong-hong-hvac 1.0.13 can die silently: a status frame with an undefined fan speed value (0x00) crashes the listener thread and nothing restarts it, so entities keep showing stale state forever with no health signal. zhong-hong-hvac 1.0.15 fixes the library (tolerant parsing, listener self-healing, health probes, reconnect), propagates send results through the HVAC control methods, and exposes ZhongHongGateway.connected / HVAC.connected. This PR: - pins zhong-hong-hvac to 1.0.15 - polls every entity every 60 s so state self-heals even when the gateway stops pushing - marks entities unavailable when the gateway connection is unhealthy - logs a warning when a control command cannot be sent - stops sending an invalid fan mode to the library when an unsupported value is requested Signed-off-by: ruohan.chen --- .../components/zhong_hong/climate.py | 33 +++++++++++++++---- .../components/zhong_hong/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index e291416562eace..e71278ce76ebff 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -1,5 +1,6 @@ """Support for ZhongHong HVAC Controller.""" +from datetime import timedelta import logging from typing import Any, override @@ -40,6 +41,7 @@ DEFAULT_PORT = 9999 DEFAULT_GATEWAY_ADDRRESS = 1 +SCAN_INTERVAL = timedelta(seconds=60) SIGNAL_DEVICE_ADDED = "zhong_hong_device_added" SIGNAL_ZHONG_HONG_HUB_START = "zhong_hong_hub_start" @@ -140,7 +142,7 @@ class ZhongHongClimate(ClimateEntity): HVACMode.FAN_ONLY, HVACMode.OFF, ] - _attr_should_poll = False + _attr_should_poll = True _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -183,6 +185,17 @@ def _after_update(self, climate): self._attr_target_temperature = self._device.target_temperature self.schedule_update_ha_state() + def update(self) -> None: + """Poll the gateway for fresh status when the connection is healthy.""" + if self._hub.connected: + self._device.update() + + @property + @override + def available(self) -> bool: + """Return False when the gateway connection is unhealthy.""" + return self._hub.connected + @property @override def hvac_mode(self) -> HVACMode: @@ -227,18 +240,23 @@ def max_temp(self) -> float: @override def turn_on(self) -> None: """Turn on ac.""" - return self._device.turn_on() + if not self._device.turn_on(): + _LOGGER.warning("%s: failed to send turn-on command", self.entity_id) @override def turn_off(self) -> None: """Turn off ac.""" - return self._device.turn_off() + if not self._device.turn_off(): + _LOGGER.warning("%s: failed to send turn-off command", self.entity_id) @override def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - self._device.set_temperature(temperature) + if not self._device.set_temperature(temperature): + _LOGGER.warning( + "%s: failed to send temperature command", self.entity_id + ) if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: self.set_hvac_mode(operation_mode) @@ -254,7 +272,8 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: if not self.is_on: self.turn_on() - self._device.set_operation_mode(hvac_mode.upper()) + if not self._device.set_operation_mode(hvac_mode.upper()): + _LOGGER.warning("%s: failed to send mode command", self.entity_id) @override def set_fan_mode(self, fan_mode: str) -> None: @@ -262,4 +281,6 @@ def set_fan_mode(self, fan_mode: str) -> None: mapped_mode = FAN_MODE_MAP.get(fan_mode) if not mapped_mode: _LOGGER.error("Unsupported fan mode: %s", fan_mode) - self._device.set_fan_mode(mapped_mode) + return + if not self._device.set_fan_mode(mapped_mode): + _LOGGER.warning("%s: failed to send fan command", self.entity_id) diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json index 3569466fb0a716..822f56fa14fa5a 100644 --- a/homeassistant/components/zhong_hong/manifest.json +++ b/homeassistant/components/zhong_hong/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["zhong_hong_hvac"], "quality_scale": "legacy", - "requirements": ["zhong-hong-hvac==1.0.13"] + "requirements": ["zhong-hong-hvac==1.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce69ca683661f1..dc0646972eb63d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3476,7 +3476,7 @@ zha-quirks==2.2.0 zha==2.1.0 # homeassistant.components.zhong_hong -zhong-hong-hvac==1.0.13 +zhong-hong-hvac==1.0.15 # homeassistant.components.ziggo_mediabox_xl ziggo-mediabox-xl==1.1.0 From 1f88dba46d0193ec562bef86c2190a9cd14daa01 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Mon, 3 Aug 2026 10:07:43 +0800 Subject: [PATCH 2/3] Add tests for zhong_hong climate platform --- tests/components/zhong_hong/__init__.py | 1 + tests/components/zhong_hong/test_climate.py | 291 ++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/components/zhong_hong/__init__.py create mode 100644 tests/components/zhong_hong/test_climate.py diff --git a/tests/components/zhong_hong/__init__.py b/tests/components/zhong_hong/__init__.py new file mode 100644 index 00000000000000..1cb5730dcd14f5 --- /dev/null +++ b/tests/components/zhong_hong/__init__.py @@ -0,0 +1 @@ +"""zhong_hong tests.""" diff --git a/tests/components/zhong_hong/test_climate.py b/tests/components/zhong_hong/test_climate.py new file mode 100644 index 00000000000000..261d318a5a57ba --- /dev/null +++ b/tests/components/zhong_hong/test_climate.py @@ -0,0 +1,291 @@ +"""Test the zhong_hong climate platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + FAN_LOW, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_OFF, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_ID = "climate.zhong_hong_hvac_1_1" +HOST = "1.2.3.4" + + +class FakeGateway: + """Test double for the zhong_hong_hvac gateway.""" + + def __init__(self) -> None: + """Initialize the fake gateway.""" + self.connected = True + self.send_result = True + self.query_status_calls = 0 + + @property + def gw_addr(self) -> int: + """Return the gateway address.""" + return 1 + + def add_status_callback(self, ac_addr, callback) -> None: + """Register a status callback (no-op).""" + + def add_device(self, device) -> None: + """Register a device (no-op).""" + + def discovery_ac(self) -> list[tuple[int, int]]: + """Return the discovered device addresses.""" + return [(1, 1)] + + def start_listen(self) -> None: + """Start listening (no-op).""" + + def query_all_status(self) -> None: + """Query all devices (no-op).""" + + def stop_listen(self) -> None: + """Stop listening (no-op).""" + + def query_status(self, ac_addr) -> bool: + """Query the status of a device.""" + self.query_status_calls += 1 + return self.send_result + + def send(self, ac_data) -> bool: + """Send a command to the gateway.""" + return self.send_result + + +@pytest.fixture +def gateway() -> FakeGateway: + """Return a fake gateway.""" + return FakeGateway() + + +async def _setup_climate(hass: HomeAssistant, gateway: FakeGateway) -> None: + """Set up the zhong_hong climate platform with a fake gateway.""" + with patch( + "homeassistant.components.zhong_hong.climate.ZhongHongGateway", + return_value=gateway, + ): + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + {CLIMATE_DOMAIN: {"platform": "zhong_hong", "host": HOST}}, + ) + await hass.async_block_till_done() + + +async def test_setup_creates_entity(hass: HomeAssistant, gateway: FakeGateway) -> None: + """Test the entity is created and polling is enabled.""" + await _setup_climate(hass, gateway) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + entity = hass.data[CLIMATE_DOMAIN].get_entity(ENTITY_ID) + assert entity is not None + assert entity.should_poll is True + + +async def test_unavailable_when_gateway_disconnected( + hass: HomeAssistant, gateway: FakeGateway +) -> None: + """Test the entity is unavailable when the gateway connection is unhealthy.""" + gateway.connected = False + await _setup_climate(hass, gateway) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_update_queries_gateway_when_connected( + hass: HomeAssistant, gateway: FakeGateway +) -> None: + """Test update polls the gateway when the connection is healthy.""" + await _setup_climate(hass, gateway) + + entity = hass.data[CLIMATE_DOMAIN].get_entity(ENTITY_ID) + assert entity is not None + await entity.async_device_update() + + assert gateway.query_status_calls == 1 + + +async def test_update_skips_gateway_when_disconnected( + hass: HomeAssistant, gateway: FakeGateway +) -> None: + """Test update does not poll the gateway when the connection is unhealthy.""" + gateway.connected = False + await _setup_climate(hass, gateway) + + entity = hass.data[CLIMATE_DOMAIN].get_entity(ENTITY_ID) + assert entity is not None + await entity.async_device_update() + + assert gateway.query_status_calls == 0 + + +async def test_turn_on_success( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn_on does not log a warning when the command is sent.""" + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert "failed to send" not in caplog.text + + +async def test_turn_on_send_failure_logs_warning( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn_on logs a warning when the command cannot be sent.""" + gateway.send_result = False + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert "failed to send turn-on command" in caplog.text + + +async def test_turn_off_send_failure_logs_warning( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn_off logs a warning when the command cannot be sent.""" + gateway.send_result = False + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert "failed to send turn-off command" in caplog.text + + +async def test_set_temperature_send_failure_logs_warning( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test set_temperature logs a warning when the command cannot be sent.""" + gateway.send_result = False + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + assert "failed to send temperature command" in caplog.text + + +async def test_set_hvac_mode_send_failure_logs_warning( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test set_hvac_mode logs a warning when the command cannot be sent.""" + gateway.send_result = False + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + assert "failed to send mode command" in caplog.text + + +async def test_set_hvac_mode_success( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test set_hvac_mode does not log a warning when the command is sent.""" + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + assert "failed to send" not in caplog.text + + +async def test_set_fan_mode_unsupported_logs_error( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test set_fan_mode with an unsupported mode logs an error and sends nothing.""" + await _setup_climate(hass, gateway) + + entity = hass.data[CLIMATE_DOMAIN].get_entity(ENTITY_ID) + assert entity is not None + await entity.async_set_fan_mode("unknown") + + assert "Unsupported fan mode: unknown" in caplog.text + assert "failed to send" not in caplog.text + + +async def test_set_fan_mode_send_failure_logs_warning( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test set_fan_mode logs a warning when the command cannot be sent.""" + gateway.send_result = False + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + + assert "failed to send fan command" in caplog.text + + +async def test_set_fan_mode_success( + hass: HomeAssistant, gateway: FakeGateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test set_fan_mode does not log a warning when the command is sent.""" + await _setup_climate(hass, gateway) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + + assert "failed to send" not in caplog.text From 2e79693b02ff3058a86f91645bca6fd692f2c980 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Mon, 3 Aug 2026 10:30:47 +0800 Subject: [PATCH 3/3] ci: rerun after unrelated flaky bootstrap test