Add SwitchBot Lock Ultra Max support (#180483)

Co-authored-by: Fan Kai <fankai@onero.com>
This commit is contained in:
Onero-testdev
2026-09-08 18:51:03 +02:00
committed by GitHub
co-authored by Fan Kai
parent 029785b938
commit 1a8ce697a7
6 changed files with 102 additions and 3 deletions
@@ -197,6 +197,11 @@ PLATFORMS_BY_TYPE = {
Platform.LOCK,
Platform.SENSOR,
],
SupportedModels.LOCK_ULTRA_MAX.value: [
Platform.BINARY_SENSOR,
Platform.LOCK,
Platform.SENSOR,
],
SupportedModels.WEATHER_STATION.value: [Platform.SENSOR],
SupportedModels.CANDLE_WARMER_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
SupportedModels.RGBIC_NEON_ROPE_LIGHT.value: [Platform.LIGHT, Platform.SENSOR],
@@ -257,6 +262,7 @@ CLASS_BY_DEVICE = {
SupportedModels.LOCK_VISION_PRO.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_VISION.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_PRO_WIFI.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_ULTRA_MAX.value: switchbot.SwitchbotLock,
SupportedModels.CANDLE_WARMER_LAMP.value: switchbot.SwitchbotCandleWarmerLamp,
SupportedModels.RGBIC_NEON_ROPE_LIGHT.value: switchbot.SwitchbotRgbicNeonLight,
SupportedModels.RGBIC_NEON_WIRE_ROPE_LIGHT.value: switchbot.SwitchbotRgbicNeonLight,
@@ -482,6 +482,7 @@ class SwitchbotOptionsFlowHandler(OptionsFlow):
SupportedModels.LOCK,
SupportedModels.LOCK_PRO,
SupportedModels.LOCK_ULTRA,
SupportedModels.LOCK_ULTRA_MAX,
SupportedModels.LOCK_PRO_WIFI,
SupportedModels.LOCK_VISION,
SupportedModels.LOCK_VISION_PRO,
@@ -47,6 +47,7 @@ class SupportedModels(StrEnum):
HUB3 = "hub3"
LOCK_LITE = "lock_lite"
LOCK_ULTRA = "lock_ultra"
LOCK_ULTRA_MAX = "lock_ultra_max"
AIR_PURIFIER_JP = "air_purifier_jp"
AIR_PURIFIER_US = "air_purifier_us"
AIR_PURIFIER_TABLE_JP = "air_purifier_table_jp"
@@ -101,6 +102,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
SwitchbotModel.LOCK_LITE: SupportedModels.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA,
SwitchbotModel.LOCK_ULTRA_MAX: SupportedModels.LOCK_ULTRA_MAX,
SwitchbotModel.AIR_PURIFIER_JP: SupportedModels.AIR_PURIFIER_JP,
SwitchbotModel.AIR_PURIFIER_US: SupportedModels.AIR_PURIFIER_US,
SwitchbotModel.AIR_PURIFIER_TABLE_JP: SupportedModels.AIR_PURIFIER_TABLE_JP,
@@ -159,6 +161,7 @@ ENCRYPTED_MODELS = {
SwitchbotModel.LOCK_PRO,
SwitchbotModel.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA,
SwitchbotModel.LOCK_ULTRA_MAX,
SwitchbotModel.AIR_PURIFIER_JP,
SwitchbotModel.AIR_PURIFIER_US,
SwitchbotModel.AIR_PURIFIER_TABLE_JP,
@@ -193,6 +196,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.LOCK_LITE: switchbot.SwitchbotLock,
SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock,
SwitchbotModel.LOCK_ULTRA_MAX: switchbot.SwitchbotLock,
SwitchbotModel.AIR_PURIFIER_JP: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_US: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE_JP: switchbot.SwitchbotAirPurifier,
+25
View File
@@ -860,6 +860,31 @@ LOCK_PRO_WIFI_SERVICE_INFO = BluetoothServiceInfoBleak(
)
LOCK_ULTRA_MAX_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Lock Ultra Max",
manufacturer_data={2409: b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91"},
service_data={
"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x80\x64\x00\x11\x9f\xb8"
},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Lock Ultra Max",
manufacturer_data={2409: b"\xb0\xe9\xfe\x11\x22\x33\x2a\x88\x18\x64\x00\x91"},
service_data={
"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x80\x64\x00\x11\x9f\xb8"
},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Lock Ultra Max"),
time=0,
connectable=True,
tx_power=-127,
)
AIR_PURIFIER_TABLE_US_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Air Purifier Table US",
manufacturer_data={
+41 -3
View File
@@ -26,6 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
LOCK_ULTRA_MAX_SERVICE_INFO,
NOT_SWITCHBOT_INFO,
USER_INPUT,
WOCURTAIN_SERVICE_INFO,
@@ -176,6 +177,40 @@ async def test_bluetooth_discovery_encrypted_key(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_discovery_lock_ultra_max(hass: HomeAssistant) -> None:
"""Test discovery of an encrypted Lock Ultra Max."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=LOCK_ULTRA_MAX_SERVICE_INFO,
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "encrypted_key"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encrypted_key"
with (
patch_async_setup_entry() as mock_setup_entry,
patch("switchbot.SwitchbotLock.verify_encryption_key", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_SENSOR_TYPE] == "lock_ultra_max"
assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_discovery_key(hass: HomeAssistant) -> None:
"""Test discovery via bluetooth with a encrypted device."""
result = await hass.config_entries.flow.async_init(
@@ -1883,15 +1918,18 @@ async def test_user_show_menu_when_no_scanners(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_options_flow_lock_pro_wifi(hass: HomeAssistant) -> None:
"""Test updating options for lock_pro_wifi."""
@pytest.mark.parametrize("sensor_type", ["lock_pro_wifi", "lock_ultra_max"])
async def test_options_flow_lock_with_night_latch(
hass: HomeAssistant, sensor_type: str
) -> None:
"""Test updating options for locks with night latch support."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "lock_pro_wifi",
CONF_SENSOR_TYPE: sensor_type,
},
options={CONF_RETRY_COUNT: 10},
unique_id="aabbccddeeff",
+25
View File
@@ -24,6 +24,7 @@ from . import (
LOCK_LITE_SERVICE_INFO,
LOCK_PRO_WIFI_SERVICE_INFO,
LOCK_SERVICE_INFO,
LOCK_ULTRA_MAX_SERVICE_INFO,
LOCK_ULTRA_SERVICE_INFO,
LOCK_VISION_PRO_SERVICE_INFO,
LOCK_VISION_SERVICE_INFO,
@@ -41,6 +42,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info
("lock", LOCK_SERVICE_INFO),
("lock_lite", LOCK_LITE_SERVICE_INFO),
("lock_ultra", LOCK_ULTRA_SERVICE_INFO),
("lock_ultra_max", LOCK_ULTRA_MAX_SERVICE_INFO),
("lock_vision", LOCK_VISION_SERVICE_INFO),
("lock_vision_pro", LOCK_VISION_PRO_SERVICE_INFO),
("lock_pro_wifi", LOCK_PRO_WIFI_SERVICE_INFO),
@@ -92,6 +94,7 @@ async def test_lock_services(
("lock", LOCK_SERVICE_INFO),
("lock_lite", LOCK_LITE_SERVICE_INFO),
("lock_ultra", LOCK_ULTRA_SERVICE_INFO),
("lock_ultra_max", LOCK_ULTRA_MAX_SERVICE_INFO),
("lock_vision", LOCK_VISION_SERVICE_INFO),
("lock_vision_pro", LOCK_VISION_PRO_SERVICE_INFO),
("lock_pro_wifi", LOCK_PRO_WIFI_SERVICE_INFO),
@@ -137,6 +140,28 @@ async def test_lock_services_with_night_latch_enabled(
mocked_instance.assert_awaited_once()
async def test_lock_ultra_max_entities(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
) -> None:
"""Test Lock Ultra Max creates lock, door, and battery entities."""
inject_bluetooth_service_info(hass, LOCK_ULTRA_MAX_SERVICE_INFO)
entry = mock_entry_encrypted_factory(sensor_type="lock_ultra_max")
entry.add_to_hass(hass)
with patch.multiple(
"homeassistant.components.switchbot.lock.switchbot.SwitchbotLock",
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("lock.test_name") is not None
assert hass.states.get("binary_sensor.test_name") is not None
assert hass.states.get("sensor.test_name_battery") is not None
@pytest.mark.parametrize(
("exception", "error_message"),
[