Skip to content

Commit aadaf87

Browse files
authored
Add switchbot relayswitch 2PM (#146140)
1 parent e70b147 commit aadaf87

File tree

8 files changed

+349
-11
lines changed

8 files changed

+349
-11
lines changed

homeassistant/components/switchbot/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
9999
SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR],
100100
SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR],
101+
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
101102
}
102103
CLASS_BY_DEVICE = {
103104
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -129,6 +130,7 @@
129130
SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight,
130131
SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight,
131132
SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
133+
SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM,
132134
}
133135

134136

homeassistant/components/switchbot/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class SupportedModels(StrEnum):
5454
RGBICWW_STRIP_LIGHT = "rgbicww_strip_light"
5555
RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp"
5656
PLUG_MINI_EU = "plug_mini_eu"
57+
RELAY_SWITCH_2PM = "relay_switch_2pm"
5758

5859

5960
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -87,6 +88,7 @@ class SupportedModels(StrEnum):
8788
SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT,
8889
SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP,
8990
SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU,
91+
SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM,
9092
}
9193

9294
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -121,6 +123,7 @@ class SupportedModels(StrEnum):
121123
SwitchbotModel.RGBICWW_STRIP_LIGHT,
122124
SwitchbotModel.RGBICWW_FLOOR_LAMP,
123125
SwitchbotModel.PLUG_MINI_EU,
126+
SwitchbotModel.RELAY_SWITCH_2PM,
124127
}
125128

126129
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@@ -140,6 +143,7 @@ class SupportedModels(StrEnum):
140143
SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight,
141144
SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight,
142145
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
146+
SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
143147
}
144148

145149
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

homeassistant/components/switchbot/entity.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
from typing import Any, Concatenate
88

9+
import switchbot
910
from switchbot import Switchbot, SwitchbotDevice
1011
from switchbot.devices.device import SwitchbotOperationError
1112

@@ -46,6 +47,7 @@ def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
4647
model=coordinator.model, # Sometimes the modelName is missing from the advertisement data
4748
name=coordinator.device_name,
4849
)
50+
self._channel: int | None = None
4951
if ":" not in self._address:
5052
# MacOS Bluetooth addresses are not mac addresses
5153
return
@@ -60,6 +62,8 @@ def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
6062
@property
6163
def parsed_data(self) -> dict[str, Any]:
6264
"""Return parsed device data for this entity."""
65+
if isinstance(self.coordinator.device, switchbot.SwitchbotRelaySwitch2PM):
66+
return self.coordinator.device.get_parsed_data(self._channel)
6367
return self.coordinator.device.parsed_data
6468

6569
@property

homeassistant/components/switchbot/sensor.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import switchbot
56
from switchbot import HumidifierWaterLevel
67
from switchbot.const.air_purifier import AirQualityLevel
78

@@ -25,8 +26,10 @@
2526
UnitOfTemperature,
2627
)
2728
from homeassistant.core import HomeAssistant
29+
from homeassistant.helpers.device_registry import DeviceInfo
2830
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
2931

32+
from .const import DOMAIN
3033
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
3134
from .entity import SwitchbotEntity
3235

@@ -133,13 +136,22 @@ async def async_setup_entry(
133136
) -> None:
134137
"""Set up Switchbot sensor based on a config entry."""
135138
coordinator = entry.runtime_data
136-
entities = [
137-
SwitchBotSensor(coordinator, sensor)
138-
for sensor in coordinator.device.parsed_data
139-
if sensor in SENSOR_TYPES
140-
]
141-
entities.append(SwitchbotRSSISensor(coordinator, "rssi"))
142-
async_add_entities(entities)
139+
sensor_entities: list[SensorEntity] = []
140+
if isinstance(coordinator.device, switchbot.SwitchbotRelaySwitch2PM):
141+
sensor_entities.extend(
142+
SwitchBotSensor(coordinator, sensor, channel)
143+
for channel in range(1, coordinator.device.channel + 1)
144+
for sensor in coordinator.device.get_parsed_data(channel)
145+
if sensor in SENSOR_TYPES
146+
)
147+
else:
148+
sensor_entities.extend(
149+
SwitchBotSensor(coordinator, sensor)
150+
for sensor in coordinator.device.parsed_data
151+
if sensor in SENSOR_TYPES
152+
)
153+
sensor_entities.append(SwitchbotRSSISensor(coordinator, "rssi"))
154+
async_add_entities(sensor_entities)
143155

144156

145157
class SwitchBotSensor(SwitchbotEntity, SensorEntity):
@@ -149,13 +161,27 @@ def __init__(
149161
self,
150162
coordinator: SwitchbotDataUpdateCoordinator,
151163
sensor: str,
164+
channel: int | None = None,
152165
) -> None:
153166
"""Initialize the Switchbot sensor."""
154167
super().__init__(coordinator)
155168
self._sensor = sensor
156-
self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}"
169+
self._channel = channel
157170
self.entity_description = SENSOR_TYPES[sensor]
158171

172+
if channel:
173+
self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}-{channel}"
174+
self._attr_device_info = DeviceInfo(
175+
identifiers={
176+
(DOMAIN, f"{coordinator.base_unique_id}-channel-{channel}")
177+
},
178+
manufacturer="SwitchBot",
179+
model_id="RelaySwitch2PM",
180+
name=f"{coordinator.device_name} Channel {channel}",
181+
)
182+
else:
183+
self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}"
184+
159185
@property
160186
def native_value(self) -> str | int | None:
161187
"""Return the state of the sensor."""

homeassistant/components/switchbot/switch.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@
22

33
from __future__ import annotations
44

5+
import logging
56
from typing import Any
67

78
import switchbot
89

910
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
1011
from homeassistant.const import STATE_ON
1112
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers.device_registry import DeviceInfo
1214
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1315
from homeassistant.helpers.restore_state import RestoreEntity
1416

17+
from .const import DOMAIN
1518
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
16-
from .entity import SwitchbotSwitchedEntity
19+
from .entity import SwitchbotSwitchedEntity, exception_handler
1720

1821
PARALLEL_UPDATES = 0
22+
_LOGGER = logging.getLogger(__name__)
1923

2024

2125
async def async_setup_entry(
@@ -24,7 +28,16 @@ async def async_setup_entry(
2428
async_add_entities: AddConfigEntryEntitiesCallback,
2529
) -> None:
2630
"""Set up Switchbot based on a config entry."""
27-
async_add_entities([SwitchBotSwitch(entry.runtime_data)])
31+
coordinator = entry.runtime_data
32+
33+
if isinstance(coordinator.device, switchbot.SwitchbotRelaySwitch2PM):
34+
entries = [
35+
SwitchbotMultiChannelSwitch(coordinator, channel)
36+
for channel in range(1, coordinator.device.channel + 1)
37+
]
38+
async_add_entities(entries)
39+
else:
40+
async_add_entities([SwitchBotSwitch(coordinator)])
2841

2942

3043
class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity):
@@ -67,3 +80,49 @@ def extra_state_attributes(self) -> dict[str, Any]:
6780
**super().extra_state_attributes,
6881
"switch_mode": self._device.switch_mode(),
6982
}
83+
84+
85+
class SwitchbotMultiChannelSwitch(SwitchbotSwitchedEntity, SwitchEntity):
86+
"""Representation of a Switchbot multi-channel switch."""
87+
88+
_attr_device_class = SwitchDeviceClass.SWITCH
89+
_device: switchbot.Switchbot
90+
_attr_name = None
91+
92+
def __init__(
93+
self, coordinator: SwitchbotDataUpdateCoordinator, channel: int
94+
) -> None:
95+
"""Initialize the Switchbot."""
96+
super().__init__(coordinator)
97+
self._channel = channel
98+
self._attr_unique_id = f"{coordinator.base_unique_id}-{channel}"
99+
100+
self._attr_device_info = DeviceInfo(
101+
identifiers={(DOMAIN, f"{coordinator.base_unique_id}-channel-{channel}")},
102+
manufacturer="SwitchBot",
103+
model_id="RelaySwitch2PM",
104+
name=f"{coordinator.device_name} Channel {channel}",
105+
)
106+
107+
@property
108+
def is_on(self) -> bool | None:
109+
"""Return true if device is on."""
110+
return self._device.is_on(self._channel)
111+
112+
@exception_handler
113+
async def async_turn_on(self, **kwargs: Any) -> None:
114+
"""Turn device on."""
115+
_LOGGER.debug(
116+
"Turn Switchbot device on %s, channel %d", self._address, self._channel
117+
)
118+
await self._device.turn_on(self._channel)
119+
self.async_write_ha_state()
120+
121+
@exception_handler
122+
async def async_turn_off(self, **kwargs: Any) -> None:
123+
"""Turn device off."""
124+
_LOGGER.debug(
125+
"Turn Switchbot device off %s, channel %d", self._address, self._channel
126+
)
127+
await self._device.turn_off(self._channel)
128+
self.async_write_ha_state()

tests/components/switchbot/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,3 +1080,28 @@ def make_advertisement(
10801080
connectable=True,
10811081
tx_power=-127,
10821082
)
1083+
1084+
1085+
RELAY_SWITCH_2PM_SERVICE_INFO = BluetoothServiceInfoBleak(
1086+
name="Relay Switch 2PM",
1087+
manufacturer_data={
1088+
2409: b"\xc0N0\xdd\xb9\xf2\x8a\xc1\x00\x00\x00\x00\x00F\x00\x00"
1089+
},
1090+
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"=\x00\x00\x00"},
1091+
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
1092+
address="AA:BB:CC:DD:EE:FF",
1093+
rssi=-60,
1094+
source="local",
1095+
advertisement=generate_advertisement_data(
1096+
local_name="Relay Switch 2PM",
1097+
manufacturer_data={
1098+
2409: b"\xc0N0\xdd\xb9\xf2\x8a\xc1\x00\x00\x00\x00\x00F\x00\x00"
1099+
},
1100+
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"=\x00\x00\x00"},
1101+
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
1102+
),
1103+
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Relay Switch 2PM"),
1104+
time=0,
1105+
connectable=True,
1106+
tx_power=-127,
1107+
)

tests/components/switchbot/test_sensor.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
HUBMINI_MATTER_SERVICE_INFO,
3030
LEAK_SERVICE_INFO,
3131
PLUG_MINI_EU_SERVICE_INFO,
32+
RELAY_SWITCH_2PM_SERVICE_INFO,
3233
REMOTE_SERVICE_INFO,
3334
WOHAND_SERVICE_INFO,
3435
WOHUB2_SERVICE_INFO,
@@ -617,3 +618,113 @@ async def test_plug_mini_eu_sensor(hass: HomeAssistant) -> None:
617618

618619
assert await hass.config_entries.async_unload(entry.entry_id)
619620
await hass.async_block_till_done()
621+
622+
623+
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
624+
async def test_relay_switch_2pm_sensor(hass: HomeAssistant) -> None:
625+
"""Test setting up creates the relay switch 2PM sensor."""
626+
await async_setup_component(hass, DOMAIN, {})
627+
inject_bluetooth_service_info(hass, RELAY_SWITCH_2PM_SERVICE_INFO)
628+
629+
with patch(
630+
"homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch2PM.get_basic_info",
631+
new=AsyncMock(
632+
return_value={
633+
1: {
634+
"power": 4.9,
635+
"current": 0.1,
636+
"voltage": 25,
637+
"energy": 0.2,
638+
},
639+
2: {
640+
"power": 7.9,
641+
"current": 0.6,
642+
"voltage": 25,
643+
"energy": 2.5,
644+
},
645+
}
646+
),
647+
):
648+
entry = MockConfigEntry(
649+
domain=DOMAIN,
650+
data={
651+
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
652+
CONF_NAME: "test-name",
653+
CONF_SENSOR_TYPE: "relay_switch_2pm",
654+
CONF_KEY_ID: "ff",
655+
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
656+
},
657+
unique_id="aabbccddeeaa",
658+
)
659+
entry.add_to_hass(hass)
660+
661+
assert await hass.config_entries.async_setup(entry.entry_id)
662+
await hass.async_block_till_done()
663+
664+
assert len(hass.states.async_all("sensor")) == 9
665+
666+
power_sensor_1 = hass.states.get("sensor.test_name_channel_1_power")
667+
power_sensor_attrs = power_sensor_1.attributes
668+
assert power_sensor_1.state == "4.9"
669+
assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Power"
670+
assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W"
671+
assert power_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
672+
673+
voltage_sensor_1 = hass.states.get("sensor.test_name_channel_1_voltage")
674+
voltage_sensor_1_attrs = voltage_sensor_1.attributes
675+
assert voltage_sensor_1.state == "25"
676+
assert voltage_sensor_1_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Voltage"
677+
assert voltage_sensor_1_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V"
678+
assert voltage_sensor_1_attrs[ATTR_STATE_CLASS] == "measurement"
679+
680+
current_sensor_1 = hass.states.get("sensor.test_name_channel_1_current")
681+
current_sensor_1_attrs = current_sensor_1.attributes
682+
assert current_sensor_1.state == "0.1"
683+
assert current_sensor_1_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Current"
684+
assert current_sensor_1_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A"
685+
assert current_sensor_1_attrs[ATTR_STATE_CLASS] == "measurement"
686+
687+
energy_sensor_1 = hass.states.get("sensor.test_name_channel_1_energy")
688+
energy_sensor_1_attrs = energy_sensor_1.attributes
689+
assert energy_sensor_1.state == "0.2"
690+
assert energy_sensor_1_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Energy"
691+
assert energy_sensor_1_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh"
692+
assert energy_sensor_1_attrs[ATTR_STATE_CLASS] == "total_increasing"
693+
694+
power_sensor_2 = hass.states.get("sensor.test_name_channel_2_power")
695+
power_sensor_2_attrs = power_sensor_2.attributes
696+
assert power_sensor_2.state == "7.9"
697+
assert power_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Power"
698+
assert power_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W"
699+
assert power_sensor_2_attrs[ATTR_STATE_CLASS] == "measurement"
700+
701+
voltage_sensor_2 = hass.states.get("sensor.test_name_channel_2_voltage")
702+
voltage_sensor_2_attrs = voltage_sensor_2.attributes
703+
assert voltage_sensor_2.state == "25"
704+
assert voltage_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Voltage"
705+
assert voltage_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V"
706+
assert voltage_sensor_2_attrs[ATTR_STATE_CLASS] == "measurement"
707+
708+
current_sensor_2 = hass.states.get("sensor.test_name_channel_2_current")
709+
current_sensor_2_attrs = current_sensor_2.attributes
710+
assert current_sensor_2.state == "0.6"
711+
assert current_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Current"
712+
assert current_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A"
713+
assert current_sensor_2_attrs[ATTR_STATE_CLASS] == "measurement"
714+
715+
energy_sensor_2 = hass.states.get("sensor.test_name_channel_2_energy")
716+
energy_sensor_2_attrs = energy_sensor_2.attributes
717+
assert energy_sensor_2.state == "2.5"
718+
assert energy_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Energy"
719+
assert energy_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh"
720+
assert energy_sensor_2_attrs[ATTR_STATE_CLASS] == "total_increasing"
721+
722+
rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal")
723+
rssi_sensor_attrs = rssi_sensor.attributes
724+
assert rssi_sensor.state == "-60"
725+
assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal"
726+
assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm"
727+
assert rssi_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
728+
729+
assert await hass.config_entries.async_unload(entry.entry_id)
730+
await hass.async_block_till_done()

0 commit comments

Comments
 (0)