Add Switcher heater support (#162588)

Co-authored-by: Shay Levy <levyshay1@gmail.com>
This commit is contained in:
YogevBokobza
2026-02-13 22:32:55 +02:00
committed by GitHub
co-authored by Shay Levy
parent 95df5b9ec9
commit b11a75d438
11 changed files with 360 additions and 13 deletions
@@ -7,6 +7,6 @@
"iot_class": "local_push",
"loggers": ["aioswitcher"],
"quality_scale": "silver",
"requirements": ["aioswitcher==6.0.0"],
"requirements": ["aioswitcher==6.1.0"],
"single_config_entry": true
}
@@ -81,7 +81,7 @@ TEMPERATURE_SENSORS: list[SwitcherSensorEntityDescription] = [
]
POWER_PLUG_SENSORS = POWER_SENSORS
WATER_HEATER_SENSORS = [*POWER_SENSORS, *TIME_SENSORS]
HEATER_SENSORS = [*POWER_SENSORS, *TIME_SENSORS]
THERMOSTAT_SENSORS = TEMPERATURE_SENSORS
@@ -100,10 +100,13 @@ async def async_setup_entry(
SwitcherSensorEntity(coordinator, description)
for description in POWER_PLUG_SENSORS
)
elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER:
elif coordinator.data.device_type.category in [
DeviceCategory.WATER_HEATER,
DeviceCategory.HEATER,
]:
async_add_entities(
SwitcherSensorEntity(coordinator, description)
for description in WATER_HEATER_SENSORS
for description in HEATER_SENSORS
)
elif coordinator.data.device_type.category == DeviceCategory.THERMOSTAT:
async_add_entities(
@@ -79,8 +79,11 @@ async def async_setup_entry(
if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG:
entities.append(SwitcherPowerPlugSwitchEntity(coordinator))
elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER:
entities.append(SwitcherWaterHeaterSwitchEntity(coordinator))
elif coordinator.data.device_type.category in [
DeviceCategory.WATER_HEATER,
DeviceCategory.HEATER,
]:
entities.append(SwitcherHeaterSwitchEntity(coordinator))
elif coordinator.data.device_type.category in (
DeviceCategory.SHUTTER,
DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT,
@@ -143,8 +146,8 @@ class SwitcherPowerPlugSwitchEntity(SwitcherBaseSwitchEntity):
_attr_device_class = SwitchDeviceClass.OUTLET
class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity):
"""Representation of a Switcher water heater switch entity."""
class SwitcherHeaterSwitchEntity(SwitcherBaseSwitchEntity):
"""Representation of a Switcher heater switch entity."""
_attr_device_class = SwitchDeviceClass.SWITCH
+1 -1
View File
@@ -413,7 +413,7 @@ aiosteamist==1.0.1
aiostreammagic==2.12.1
# homeassistant.components.switcher_kis
aioswitcher==6.0.0
aioswitcher==6.1.0
# homeassistant.components.syncthing
aiosyncthing==0.7.1
+1 -1
View File
@@ -398,7 +398,7 @@ aiosteamist==1.0.1
aiostreammagic==2.12.1
# homeassistant.components.switcher_kis
aioswitcher==6.0.0
aioswitcher==6.1.0
# homeassistant.components.syncthing
aiosyncthing==0.7.1
+25 -1
View File
@@ -6,6 +6,7 @@ from aioswitcher.device import (
ShutterChildLock,
ShutterDirection,
SwitcherDualShutterSingleLight,
SwitcherHeater,
SwitcherLight,
SwitcherPowerPlug,
SwitcherShutter,
@@ -28,6 +29,7 @@ DUMMY_DEVICE_ID6 = "bcdc64"
DUMMY_DEVICE_ID7 = "bcdd64"
DUMMY_DEVICE_ID8 = "bcde64"
DUMMY_DEVICE_ID9 = "bcdf64"
DUMMY_DEVICE_ID10 = "cafe66"
DUMMY_DEVICE_KEY1 = "18"
DUMMY_DEVICE_KEY2 = "01"
DUMMY_DEVICE_KEY3 = "12"
@@ -37,8 +39,9 @@ DUMMY_DEVICE_KEY6 = "16"
DUMMY_DEVICE_KEY7 = "17"
DUMMY_DEVICE_KEY8 = "18"
DUMMY_DEVICE_KEY9 = "19"
DUMMY_DEVICE_KEY10 = "20"
DUMMY_DEVICE_NAME1 = "Plug 23BC"
DUMMY_DEVICE_NAME2 = "Heater FE12"
DUMMY_DEVICE_NAME2 = "WaterHeater FE12"
DUMMY_DEVICE_NAME3 = "Breeze AB39"
DUMMY_DEVICE_NAME4 = "Runner DD77"
DUMMY_DEVICE_NAME5 = "RunnerS11 6CF5"
@@ -46,9 +49,11 @@ DUMMY_DEVICE_NAME6 = "RunnerS12 A9BE"
DUMMY_DEVICE_NAME7 = "Light 36BB"
DUMMY_DEVICE_NAME8 = "Light 36CB"
DUMMY_DEVICE_NAME9 = "Light 36DB"
DUMMY_DEVICE_NAME10 = "Heater FE66"
DUMMY_DEVICE_PASSWORD = "12345678"
DUMMY_ELECTRIC_CURRENT1 = 0.5
DUMMY_ELECTRIC_CURRENT2 = 12.8
DUMMY_ELECTRIC_CURRENT3 = 10.0
DUMMY_IP_ADDRESS1 = "192.168.100.157"
DUMMY_IP_ADDRESS2 = "192.168.100.158"
DUMMY_IP_ADDRESS3 = "192.168.100.159"
@@ -58,6 +63,7 @@ DUMMY_IP_ADDRESS6 = "192.168.100.162"
DUMMY_IP_ADDRESS7 = "192.168.100.163"
DUMMY_IP_ADDRESS8 = "192.168.100.164"
DUMMY_IP_ADDRESS9 = "192.168.100.165"
DUMMY_IP_ADDRESS10 = "192.168.100.166"
DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8"
DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9"
DUMMY_MAC_ADDRESS3 = "A1:B2:C3:45:67:DA"
@@ -67,6 +73,7 @@ DUMMY_MAC_ADDRESS6 = "A1:B2:C3:45:67:DD"
DUMMY_MAC_ADDRESS7 = "A1:B2:C3:45:67:DE"
DUMMY_MAC_ADDRESS8 = "A1:B2:C3:45:67:DF"
DUMMY_MAC_ADDRESS9 = "A1:B2:C3:45:67:DG"
DUMMY_MAC_ADDRESS10 = "A1:B2:C3:45:67:DH"
DUMMY_TOKEN_NEEDED1 = False
DUMMY_TOKEN_NEEDED2 = False
DUMMY_TOKEN_NEEDED3 = False
@@ -76,9 +83,11 @@ DUMMY_TOKEN_NEEDED6 = True
DUMMY_TOKEN_NEEDED7 = True
DUMMY_TOKEN_NEEDED8 = True
DUMMY_TOKEN_NEEDED9 = True
DUMMY_TOKEN_NEEDED10 = True
DUMMY_PHONE_ID = "1234"
DUMMY_POWER_CONSUMPTION1 = 100
DUMMY_POWER_CONSUMPTION2 = 2780
DUMMY_POWER_CONSUMPTION3 = 1000
DUMMY_REMAINING_TIME = "01:29:32"
DUMMY_TIMER_MINUTES_SET = "90"
DUMMY_THERMOSTAT_MODE = ThermostatMode.COOL
@@ -127,6 +136,21 @@ DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater(
DUMMY_AUTO_SHUT_DOWN,
)
DUMMY_HEATER_DEVICE = SwitcherHeater(
DeviceType.HEATER,
DeviceState.ON,
DUMMY_DEVICE_ID10,
DUMMY_DEVICE_KEY10,
DUMMY_IP_ADDRESS10,
DUMMY_MAC_ADDRESS10,
DUMMY_DEVICE_NAME10,
DUMMY_TOKEN_NEEDED10,
DUMMY_POWER_CONSUMPTION3,
DUMMY_ELECTRIC_CURRENT3,
DUMMY_REMAINING_TIME,
DUMMY_AUTO_SHUT_DOWN,
)
DUMMY_SHUTTER_DEVICE = SwitcherShutter(
DeviceType.RUNNER,
DeviceState.ON,
@@ -18,7 +18,7 @@
'ip_address': '**REDACTED**',
'last_data_update': '2022-09-28T16:42:12.706017',
'mac_address': '**REDACTED**',
'name': 'Heater FE12',
'name': 'WaterHeater FE12',
'power_consumption': 2780,
'remaining_time': '01:29:32',
'token_needed': False,
+115 -1
View File
@@ -14,7 +14,15 @@ from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util, slugify
from . import init_integration
from .consts import DUMMY_DEVICE_ID1, DUMMY_DEVICE_ID4, DUMMY_SWITCHER_DEVICES
from .consts import (
DUMMY_DEVICE_ID1,
DUMMY_DEVICE_ID4,
DUMMY_DEVICE_ID10,
DUMMY_HEATER_DEVICE,
DUMMY_SWITCHER_DEVICES,
DUMMY_TOKEN as TOKEN,
DUMMY_USERNAME as USERNAME,
)
from tests.common import async_fire_time_changed
from tests.typing import WebSocketGenerator
@@ -71,6 +79,57 @@ async def test_update_fail(
assert state.state != STATE_UNAVAILABLE
async def test_update_fail_token_needed(
hass: HomeAssistant,
mock_bridge,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities state unavailable when updates fail.."""
entry = await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
device = DUMMY_HEATER_DEVICE
mock_bridge.mock_callbacks([DUMMY_HEATER_DEVICE])
await hass.async_block_till_done()
assert mock_bridge.is_running is True
assert len(entry.runtime_data) == 1
freezer.tick(timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
f"Device {device.name} did not send update for {MAX_UPDATE_INTERVAL_SEC} seconds"
in caplog.text
)
entity_id = f"switch.{slugify(device.name)}"
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
entity_id = f"sensor.{slugify(device.name)}_power"
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
mock_bridge.mock_callbacks([DUMMY_HEATER_DEVICE])
await hass.async_block_till_done()
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 2)
)
await hass.async_block_till_done()
entity_id = f"switch.{slugify(device.name)}"
state = hass.states.get(entity_id)
assert state.state != STATE_UNAVAILABLE
entity_id = f"sensor.{slugify(device.name)}_power"
state = hass.states.get(entity_id)
assert state.state != STATE_UNAVAILABLE
async def test_entry_unload(hass: HomeAssistant, mock_bridge) -> None:
"""Test entry unload."""
entry = await init_integration(hass)
@@ -139,3 +198,58 @@ async def test_remove_device(
assert (
device_registry.async_get_device(identifiers={(DOMAIN, dead_device_id)}) is None
)
async def test_remove_device_token_needed(
hass: HomeAssistant,
mock_bridge,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test being able to remove a disconnected device."""
assert await async_setup_component(hass, "config", {})
entry = await init_integration(hass, USERNAME, TOKEN)
entry_id = entry.entry_id
assert mock_bridge
mock_bridge.mock_callbacks([DUMMY_HEATER_DEVICE])
await hass.async_block_till_done()
assert mock_bridge.is_running is True
assert len(entry.runtime_data) == 1
live_device_id = DUMMY_DEVICE_ID10
dead_device_id = DUMMY_DEVICE_ID4
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1
# Create a dead device
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, dead_device_id)},
manufacturer="Switcher",
model="Switcher Model",
name="Switcher Device",
)
await hass.async_block_till_done()
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2
# Try to remove a live device - fails
device = device_registry.async_get_device(identifiers={(DOMAIN, live_device_id)})
client = await hass_ws_client(hass)
response = await client.remove_device(device.id, entry_id)
assert not response["success"]
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2
assert (
device_registry.async_get_device(identifiers={(DOMAIN, live_device_id)})
is not None
)
# Try to remove a dead device - succeeds
device = device_registry.async_get_device(identifiers={(DOMAIN, dead_device_id)})
response = await client.remove_device(device.id, entry_id)
assert response["success"]
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1
assert (
device_registry.async_get_device(identifiers={(DOMAIN, dead_device_id)}) is None
)
@@ -7,9 +7,12 @@ from homeassistant.util import slugify
from . import init_integration
from .consts import (
DUMMY_HEATER_DEVICE,
DUMMY_PLUG_DEVICE,
DUMMY_SWITCHER_SENSORS_DEVICES,
DUMMY_THERMOSTAT_DEVICE,
DUMMY_TOKEN as TOKEN,
DUMMY_USERNAME as USERNAME,
DUMMY_WATER_HEATER_DEVICE,
)
@@ -37,6 +40,17 @@ DEVICE_SENSORS_TUPLE = (
),
)
DEVICE_SENSORS_TOKEN_NEEDED_TUPLE = (
(
DUMMY_HEATER_DEVICE,
[
("power", "power_consumption"),
("current", "electric_current"),
("remaining_time", "remaining_time"),
],
),
)
@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_SENSORS_DEVICES], indirect=True)
async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None:
@@ -54,6 +68,22 @@ async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None:
assert state.state == str(getattr(device, field))
@pytest.mark.parametrize("mock_bridge", [[DUMMY_HEATER_DEVICE]], indirect=True)
async def test_sensor_platform_token_needed(hass: HomeAssistant, mock_bridge) -> None:
"""Test sensor platform."""
entry = await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
assert mock_bridge.is_running is True
assert len(entry.runtime_data) == 1
for device, sensors in DEVICE_SENSORS_TOKEN_NEEDED_TUPLE:
for sensor, field in sensors:
entity_id = f"sensor.{slugify(device.name)}_{sensor}"
state = hass.states.get(entity_id)
assert state.state == str(getattr(device, field))
@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True)
async def test_sensor_update(
hass: HomeAssistant, mock_bridge, monkeypatch: pytest.MonkeyPatch
@@ -75,3 +105,26 @@ async def test_sensor_update(
state = hass.states.get(entity_id)
assert state.state == "1431"
@pytest.mark.parametrize("mock_bridge", [[DUMMY_HEATER_DEVICE]], indirect=True)
async def test_sensor_update_token_needed(
hass: HomeAssistant, mock_bridge, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test sensor update."""
await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
device = DUMMY_HEATER_DEVICE
field = "power_consumption"
entity_id = f"sensor.{slugify(device.name)}_power"
state = hass.states.get(entity_id)
assert state.state == str(getattr(device, field))
monkeypatch.setattr(device, field, 1431)
mock_bridge.mock_callbacks([device])
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == "1431"
@@ -23,8 +23,11 @@ from homeassistant.util import slugify
from . import init_integration
from .consts import (
DUMMY_AUTO_OFF_SET,
DUMMY_HEATER_DEVICE,
DUMMY_PLUG_DEVICE,
DUMMY_TIMER_MINUTES_SET,
DUMMY_TOKEN as TOKEN,
DUMMY_USERNAME as USERNAME,
DUMMY_WATER_HEATER_DEVICE,
)
@@ -69,6 +72,46 @@ async def test_turn_on_with_timer_service(
assert state.state == STATE_ON
@pytest.mark.parametrize("mock_bridge", [[DUMMY_HEATER_DEVICE]], indirect=True)
async def test_turn_on_with_timer_service_token_needed(
hass: HomeAssistant, mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test the turn on with timer service."""
await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
device = DUMMY_HEATER_DEVICE
entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}"
# Test initial state - off
monkeypatch.setattr(device, "device_state", DeviceState.OFF)
mock_bridge.mock_callbacks([DUMMY_HEATER_DEVICE])
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.control_device"
) as mock_control_device:
await hass.services.async_call(
DOMAIN,
SERVICE_TURN_ON_WITH_TIMER_NAME,
{
ATTR_ENTITY_ID: entity_id,
CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET,
},
blocking=True,
)
assert mock_api.call_count == 2
mock_control_device.assert_called_once_with(
Command.ON, int(DUMMY_TIMER_MINUTES_SET)
)
state = hass.states.get(entity_id)
assert state.state == STATE_ON
@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True)
async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api) -> None:
"""Test the set auto off service."""
@@ -94,6 +137,31 @@ async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api)
)
@pytest.mark.parametrize("mock_bridge", [[DUMMY_HEATER_DEVICE]], indirect=True)
async def test_set_auto_off_service_token_needed(
hass: HomeAssistant, mock_bridge, mock_api
) -> None:
"""Test the set auto off service."""
await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
device = DUMMY_HEATER_DEVICE
entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}"
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.set_auto_shutdown"
) as mock_set_auto_shutdown:
await hass.services.async_call(
DOMAIN,
SERVICE_SET_AUTO_OFF_NAME,
{ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET},
blocking=True,
)
assert mock_api.call_count == 2
mock_set_auto_shutdown.assert_called_once_with(time_period_str(DUMMY_AUTO_OFF_SET))
@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True)
async def test_set_auto_off_service_fail(
hass: HomeAssistant, mock_bridge, mock_api
@@ -125,6 +193,37 @@ async def test_set_auto_off_service_fail(
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("mock_bridge", [[DUMMY_HEATER_DEVICE]], indirect=True)
async def test_set_auto_off_service_fail_token_needed(
hass: HomeAssistant, mock_bridge, mock_api
) -> None:
"""Test set auto off service failed."""
await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
device = DUMMY_HEATER_DEVICE
entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}"
with (
patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.set_auto_shutdown",
return_value=None,
) as mock_set_auto_shutdown,
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_AUTO_OFF_NAME,
{ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET},
blocking=True,
)
assert mock_api.call_count == 2
mock_set_auto_shutdown.assert_called_once_with(time_period_str(DUMMY_AUTO_OFF_SET))
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True)
async def test_plug_unsupported_services(
hass: HomeAssistant, mock_bridge, mock_api, caplog: pytest.LogCaptureFixture
@@ -23,6 +23,7 @@ from homeassistant.util import slugify
from . import init_integration
from .consts import (
DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3,
DUMMY_HEATER_DEVICE,
DUMMY_PLUG_DEVICE,
DUMMY_SHUTTER_DEVICE as DEVICE,
DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2,
@@ -87,6 +88,56 @@ async def test_switch(
assert state.state == STATE_OFF
@pytest.mark.parametrize("mock_bridge", [[DUMMY_HEATER_DEVICE]], indirect=True)
async def test_switch_token_needed(
hass: HomeAssistant, mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test the switch."""
await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
device = DUMMY_HEATER_DEVICE
entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}"
# Test initial state - on
state = hass.states.get(entity_id)
assert state.state == STATE_ON
# Test state change on --> off
monkeypatch.setattr(device, "device_state", DeviceState.OFF)
mock_bridge.mock_callbacks([device])
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
# Test turning on
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.control_device",
) as mock_control_device:
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert mock_api.call_count == 2
mock_control_device.assert_called_once_with(Command.ON)
state = hass.states.get(entity_id)
assert state.state == STATE_ON
# Test turning off
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.control_device"
) as mock_control_device:
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert mock_api.call_count == 4
mock_control_device.assert_called_once_with(Command.OFF)
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True)
async def test_switch_ignore_previous_async_state(
hass: HomeAssistant, mock_bridge, mock_api