1
0
Эх сурвалжийг харах

feat!: distinguish SwitchBot product models (#579)

* feat: distinguish SwitchBot product models

Split Meter, Plug Mini, Curtain, and Ceiling Light variants in API and BLE mappings. Correct the ordinary Hub Mini identity while preserving the existing Matter name and deprecated compatibility symbols. Add mapping and compatibility tests.

* fix!: clarify model compatibility and cover cloud parsing

Clarify that PLUG_MINI is import-only, verify regional passive parsing, and exercise cloud Hub Mini identities through the model cache. Test canonical enum names and old-name lookups.

BREAKING CHANGE: Product-specific modelName values replace shared identities for Curtain 3, Meter Plus, Plug Mini US/JP, Ceiling Light Pro, and ordinary Hub Mini. METER_PRO_C and IO_METER now expose the new canonical .name strings. PLUG_MINI remains importable but no longer selects a BLE parser; use PLUG_MINI_US or PLUG_MINI_JP. Coordinate the major release with downstream model mappings and configuration migrations.
Retha Runolfsson 5 өдөр өмнө
parent
commit
8c393236b0

+ 18 - 18
switchbot/adv_parser.py

@@ -129,13 +129,13 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "manufacturer_id": 2409,
     },
     "{": {
-        "modelName": SwitchbotModel.CURTAIN,
+        "modelName": SwitchbotModel.CURTAIN_3,
         "modelFriendlyName": "Curtain 3",
         "func": process_wocurtain,
         "manufacturer_id": 2409,
     },
     "[": {
-        "modelName": SwitchbotModel.CURTAIN,
+        "modelName": SwitchbotModel.CURTAIN_3,
         "modelFriendlyName": "Curtain 3",
         "func": process_wocurtain,
         "manufacturer_id": 2409,
@@ -153,25 +153,25 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "manufacturer_id": 2409,
     },
     "w": {
-        "modelName": SwitchbotModel.IO_METER,
-        "modelFriendlyName": "Indoor/Outdoor Meter",
+        "modelName": SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
+        "modelFriendlyName": "Indoor/Outdoor Thermo-Hygrometer",
         "func": process_wosensorth,
         "manufacturer_id": 2409,
     },
     "W": {
-        "modelName": SwitchbotModel.IO_METER,
-        "modelFriendlyName": "Indoor/Outdoor Meter",
+        "modelName": SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
+        "modelFriendlyName": "Indoor/Outdoor Thermo-Hygrometer",
         "func": process_wosensorth,
         "manufacturer_id": 2409,
     },
     "i": {
-        "modelName": SwitchbotModel.METER,
+        "modelName": SwitchbotModel.METER_PLUS,
         "modelFriendlyName": "Meter Plus",
         "func": process_wosensorth,
         "manufacturer_id": 2409,
     },
     "I": {
-        "modelName": SwitchbotModel.METER,
+        "modelName": SwitchbotModel.METER_PLUS,
         "modelFriendlyName": "Meter Plus",
         "func": process_wosensorth,
         "manufacturer_id": 2409,
@@ -201,13 +201,13 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "manufacturer_id": 2409,
     },
     "5": {
-        "modelName": SwitchbotModel.METER_PRO_C,
+        "modelName": SwitchbotModel.METER_PRO_CO2,
         "modelFriendlyName": "Meter Pro CO2",
         "func": process_wosensorth_c,
         "manufacturer_id": 2409,
     },
     b"\x15": {
-        "modelName": SwitchbotModel.METER_PRO_C,
+        "modelName": SwitchbotModel.METER_PRO_CO2,
         "modelFriendlyName": "Meter Pro CO2",
         "func": process_wosensorth_c,
         "manufacturer_id": 2409,
@@ -225,25 +225,25 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "manufacturer_id": 2409,
     },
     "g": {
-        "modelName": SwitchbotModel.PLUG_MINI,
-        "modelFriendlyName": "Plug Mini",
+        "modelName": SwitchbotModel.PLUG_MINI_US,
+        "modelFriendlyName": "Plug Mini (US)",
         "func": process_woplugmini,
         "manufacturer_id": 2409,
     },
     "G": {
-        "modelName": SwitchbotModel.PLUG_MINI,
-        "modelFriendlyName": "Plug Mini",
+        "modelName": SwitchbotModel.PLUG_MINI_US,
+        "modelFriendlyName": "Plug Mini (US)",
         "func": process_woplugmini,
         "manufacturer_id": 2409,
     },
     "j": {
-        "modelName": SwitchbotModel.PLUG_MINI,
+        "modelName": SwitchbotModel.PLUG_MINI_JP,
         "modelFriendlyName": "Plug Mini (JP)",
         "func": process_woplugmini,
         "manufacturer_id": 2409,
     },
     "J": {
-        "modelName": SwitchbotModel.PLUG_MINI,
+        "modelName": SwitchbotModel.PLUG_MINI_JP,
         "modelFriendlyName": "Plug Mini (JP)",
         "func": process_woplugmini,
         "manufacturer_id": 2409,
@@ -273,13 +273,13 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "manufacturer_id": 2409,
     },
     "n": {
-        "modelName": SwitchbotModel.CEILING_LIGHT,
+        "modelName": SwitchbotModel.CEILING_LIGHT_PRO,
         "modelFriendlyName": "Ceiling Light Pro",
         "func": process_woceiling,
         "manufacturer_id": 2409,
     },
     "N": {
-        "modelName": SwitchbotModel.CEILING_LIGHT,
+        "modelName": SwitchbotModel.CEILING_LIGHT_PRO,
         "modelFriendlyName": "Ceiling Light Pro",
         "func": process_woceiling,
         "manufacturer_id": 2409,

+ 11 - 0
switchbot/const/__init__.py

@@ -63,17 +63,27 @@ class SwitchbotAccountConnectionError(RuntimeError):
 class SwitchbotModel(StrEnum):
     BOT = "WoHand"
     CURTAIN = "WoCurtain"
+    CURTAIN_3 = "WoCurtain3"
     HUMIDIFIER = "WoHumi"
+    # Deprecated import-only symbol; use PLUG_MINI_US or PLUG_MINI_JP to parse.
     PLUG_MINI = "WoPlug"
+    PLUG_MINI_US = "WoPlugUS"
+    PLUG_MINI_JP = "WoPlugJP"
     CONTACT_SENSOR = "WoContact"
     LIGHT_STRIP = "WoStrip"
     METER = "WoSensorTH"
+    METER_PLUS = "WoMeterPlus"
     METER_PRO = "WoTHP"
+    METER_PRO_CO2 = "WoTHPc"
+    # Deprecated alias; use METER_PRO_CO2.
     METER_PRO_C = "WoTHPc"
+    INDOOR_OUTDOOR_THERMO_HYGROMETER = "WoIOSensorTH"
+    # Deprecated alias; use INDOOR_OUTDOOR_THERMO_HYGROMETER.
     IO_METER = "WoIOSensorTH"
     MOTION_SENSOR = "WoPresence"
     COLOR_BULB = "WoBulb"
     CEILING_LIGHT = "WoCeiling"
+    CEILING_LIGHT_PRO = "WoCeilingPro"
     LOCK = "WoLock"
     LOCK_PRO = "WoLockPro"
     BLIND_TILT = "WoBlindTilt"
@@ -86,6 +96,7 @@ class SwitchbotModel(StrEnum):
     UNIVERSAL_REMOTE = "WoUniversalRemote"
     EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
     ROLLER_SHADE = "Roller Shade"
+    HUB_MINI = "WoLinkMini"
     HUBMINI_MATTER = "HubMini Matter"
     CIRCULATOR_FAN = "Circulator Fan"
     CIRCULATOR_FAN_PRO = "Circulator Fan Pro"

+ 9 - 8
switchbot/devices/device.py

@@ -65,27 +65,28 @@ def _extract_region(userinfo: dict[str, Any]) -> str:
 API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "WoHand": SwitchbotModel.BOT,
     "WoCurtain": SwitchbotModel.CURTAIN,
-    "WoCurtain3": SwitchbotModel.CURTAIN,  # Curtain3
+    "WoCurtain3": SwitchbotModel.CURTAIN_3,
     "WoHumi": SwitchbotModel.HUMIDIFIER,
     "WoHumi2": SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
-    "WoPlug": SwitchbotModel.PLUG_MINI,
-    "WoPlugUS": SwitchbotModel.PLUG_MINI,
+    "WoPlugUS": SwitchbotModel.PLUG_MINI_US,
+    "WoPlugJP": SwitchbotModel.PLUG_MINI_JP,
     "WoContact": SwitchbotModel.CONTACT_SENSOR,
     "WoStrip": SwitchbotModel.LIGHT_STRIP,
     "WoMeter": SwitchbotModel.METER,
-    "WoMeterPlus": SwitchbotModel.METER,  # Meter Plus
+    "WoMeterPlus": SwitchbotModel.METER_PLUS,
     "WoPresence": SwitchbotModel.MOTION_SENSOR,
     "WoBulb": SwitchbotModel.COLOR_BULB,
     "WoCeiling": SwitchbotModel.CEILING_LIGHT,
-    "WoCeilingPro": SwitchbotModel.CEILING_LIGHT,  # Ceiling Light Pro
+    "WoCeilingPro": SwitchbotModel.CEILING_LIGHT_PRO,
     "WoLock": SwitchbotModel.LOCK,
     "WoLockPro": SwitchbotModel.LOCK_PRO,
     "WoLockLite": SwitchbotModel.LOCK_LITE,
     "WoBlindTilt": SwitchbotModel.BLIND_TILT,
-    "WoIOSensor": SwitchbotModel.IO_METER,  # Outdoor Meter
+    "WoIOSensor": SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
     "WoButton": SwitchbotModel.REMOTE,  # Remote button
     "WoUniversalRemote": SwitchbotModel.UNIVERSAL_REMOTE,  # Universal Remote
-    "WoLinkMini": SwitchbotModel.HUBMINI_MATTER,  # Hub Mini
+    "WoLinkMini": SwitchbotModel.HUB_MINI,
+    "WoLinkMatter": SwitchbotModel.HUBMINI_MATTER,
     "WoFan2": SwitchbotModel.CIRCULATOR_FAN,
     "WoHub2": SwitchbotModel.HUB2,
     "WoRollerShade": SwitchbotModel.ROLLER_SHADE,
@@ -104,7 +105,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "W1083001": SwitchbotModel.RELAY_SWITCH_2PM,
     "W1083002": SwitchbotModel.RELAY_SWITCH_1,  # Relay Switch 1
     "W1079000": SwitchbotModel.METER_PRO,  # Meter Pro (another variant)
-    "W1079001": SwitchbotModel.METER_PRO_C,
+    "W1079001": SwitchbotModel.METER_PRO_CO2,
     "W1101000": SwitchbotModel.PRESENCE_SENSOR,
     "W1091000": SwitchbotModel.LOCK_ULTRA,
     "W1096000": SwitchbotModel.HUB3,

+ 102 - 14
tests/test_adv_parser.py

@@ -10,6 +10,7 @@ from bleak.backends.scanner import AdvertisementData
 from switchbot import HumidifierMode, SwitchbotModel
 from switchbot.adv_parser import (
     _MODEL_TO_MAC_CACHE,
+    SUPPORTED_TYPES,
     parse_advertisement_data,
     populate_model_to_mac_cache,
 )
@@ -66,6 +67,93 @@ def generate_advertisement_data(**kwargs: Any) -> AdvertisementData:
     return AdvertisementData(**new)
 
 
+@pytest.mark.parametrize(
+    ("model_char", "expected_model", "expected_name"),
+    [
+        ("c", SwitchbotModel.CURTAIN, "Curtain"),
+        ("C", SwitchbotModel.CURTAIN, "Curtain"),
+        ("{", SwitchbotModel.CURTAIN_3, "Curtain 3"),
+        ("[", SwitchbotModel.CURTAIN_3, "Curtain 3"),
+        ("T", SwitchbotModel.METER, "Meter"),
+        ("t", SwitchbotModel.METER, "Meter"),
+        ("i", SwitchbotModel.METER_PLUS, "Meter Plus"),
+        ("I", SwitchbotModel.METER_PLUS, "Meter Plus"),
+        (
+            "w",
+            SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
+            "Indoor/Outdoor Thermo-Hygrometer",
+        ),
+        (
+            "W",
+            SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
+            "Indoor/Outdoor Thermo-Hygrometer",
+        ),
+        ("4", SwitchbotModel.METER_PRO, "Meter Pro"),
+        (b"\x14", SwitchbotModel.METER_PRO, "Meter Pro"),
+        ("5", SwitchbotModel.METER_PRO_CO2, "Meter Pro CO2"),
+        (b"\x15", SwitchbotModel.METER_PRO_CO2, "Meter Pro CO2"),
+        ("g", SwitchbotModel.PLUG_MINI_US, "Plug Mini (US)"),
+        ("G", SwitchbotModel.PLUG_MINI_US, "Plug Mini (US)"),
+        ("j", SwitchbotModel.PLUG_MINI_JP, "Plug Mini (JP)"),
+        ("J", SwitchbotModel.PLUG_MINI_JP, "Plug Mini (JP)"),
+        ("q", SwitchbotModel.CEILING_LIGHT, "Ceiling Light"),
+        ("Q", SwitchbotModel.CEILING_LIGHT, "Ceiling Light"),
+        ("n", SwitchbotModel.CEILING_LIGHT_PRO, "Ceiling Light Pro"),
+        ("N", SwitchbotModel.CEILING_LIGHT_PRO, "Ceiling Light Pro"),
+        ("%", SwitchbotModel.HUBMINI_MATTER, "HubMini Matter"),
+        (b"\x05", SwitchbotModel.HUBMINI_MATTER, "HubMini Matter"),
+    ],
+)
+def test_supported_type_model_mapping(
+    model_char: str | bytes,
+    expected_model: SwitchbotModel,
+    expected_name: str,
+) -> None:
+    """Test that BLE model identifiers map to a single product."""
+    assert SUPPORTED_TYPES[model_char]["modelName"] is expected_model
+    assert SUPPORTED_TYPES[model_char]["modelFriendlyName"] == expected_name
+
+
+def test_hub_mini_is_not_parsed_as_hub_mini_matter() -> None:
+    """Test that regular Hub Mini identifiers are not treated as Matter."""
+    assert "M" not in SUPPORTED_TYPES
+    assert "m" not in SUPPORTED_TYPES
+
+
+def test_deprecated_plug_mini_is_not_a_parsing_hint() -> None:
+    """The legacy symbol does not select a regional Plug Mini parser."""
+    ble_device = generate_ble_device("AA:BB:CC:DD:EE:FF")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: bytes.fromhex("AABBCCDDEEFF0080003C000A")},
+    )
+
+    assert (
+        parse_advertisement_data(ble_device, adv_data, SwitchbotModel.PLUG_MINI) is None
+    )
+
+
+@pytest.mark.parametrize(
+    "model",
+    [
+        pytest.param(SwitchbotModel.PLUG_MINI_US, id="us"),
+        pytest.param(SwitchbotModel.PLUG_MINI_JP, id="jp"),
+    ],
+)
+def test_regional_plug_mini_passive_parsing(model: SwitchbotModel) -> None:
+    """Regional model hints identify passive Plug Mini advertisements."""
+    ble_device = generate_ble_device("AA:BB:CC:DD:EE:FF")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: bytes.fromhex("AABBCCDDEEFF0080003C000A")},
+    )
+
+    result = parse_advertisement_data(ble_device, adv_data, model)
+
+    assert result is not None
+    assert result.data["modelName"] is model
+    assert result.data["data"]["isOn"] is True
+    assert result.active is False
+
+
 def test_parse_advertisement_data_curtain():
     """Test parse_advertisement_data for curtain."""
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
@@ -120,8 +208,8 @@ def test_parse_advertisement_data_curtain_passive():
                 "deviceChain": 1,
             },
             "isEncrypted": False,
-            "model": "{",
-            "modelFriendlyName": "Curtain 3",
+            "model": "c",
+            "modelFriendlyName": "Curtain",
             "modelName": SwitchbotModel.CURTAIN,
         },
         device=ble_device,
@@ -152,8 +240,8 @@ def test_parse_advertisement_data_curtain_passive_12_bytes():
                 "deviceChain": 1,
             },
             "isEncrypted": False,
-            "model": "{",
-            "modelFriendlyName": "Curtain 3",
+            "model": "c",
+            "modelFriendlyName": "Curtain",
             "modelName": SwitchbotModel.CURTAIN,
         },
         device=ble_device,
@@ -391,7 +479,7 @@ def test_parse_advertisement_data_curtain3():
             "isEncrypted": False,
             "model": "{",
             "modelFriendlyName": "Curtain 3",
-            "modelName": SwitchbotModel.CURTAIN,
+            "modelName": SwitchbotModel.CURTAIN_3,
         },
         device=ble_device,
         rssi=-80,
@@ -409,7 +497,7 @@ def test_parse_advertisement_data_curtain3_passive():
         service_data={},
         rssi=-80,
     )
-    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.CURTAIN)
+    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.CURTAIN_3)
     assert result == SwitchBotAdvertisement(
         address="aa:bb:cc:dd:ee:ff",
         data={
@@ -425,7 +513,7 @@ def test_parse_advertisement_data_curtain3_passive():
             "isEncrypted": False,
             "model": "{",
             "modelFriendlyName": "Curtain 3",
-            "modelName": SwitchbotModel.CURTAIN,
+            "modelName": SwitchbotModel.CURTAIN_3,
         },
         device=ble_device,
         rssi=-80,
@@ -902,8 +990,8 @@ def test_wosensor_passive_only():
                 "temperature": 24.6,
             },
             "isEncrypted": False,
-            "model": "i",
-            "modelFriendlyName": "Meter Plus",
+            "model": "T",
+            "modelFriendlyName": "Meter",
             "modelName": SwitchbotModel.METER,
             "rawAdvData": None,
         },
@@ -1018,8 +1106,8 @@ def test_woiosensor_passive_and_active():
             },
             "isEncrypted": False,
             "model": "w",
-            "modelFriendlyName": "Indoor/Outdoor Meter",
-            "modelName": SwitchbotModel.IO_METER,
+            "modelFriendlyName": "Indoor/Outdoor Thermo-Hygrometer",
+            "modelName": SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
             "rawAdvData": b"w\x00\xe4",
         },
         device=ble_device,
@@ -1050,8 +1138,8 @@ def test_woiosensor_passive_only():
             },
             "isEncrypted": False,
             "model": "w",
-            "modelFriendlyName": "Indoor/Outdoor Meter",
-            "modelName": SwitchbotModel.IO_METER,
+            "modelFriendlyName": "Indoor/Outdoor Thermo-Hygrometer",
+            "modelName": SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER,
             "rawAdvData": None,
         },
         device=ble_device,
@@ -4950,7 +5038,7 @@ def test_parse_advertisement_with_mac_cache_curtain() -> None:
     result_with_cache = parse_advertisement_data(ble_device, adv_data)
     assert result_with_cache is not None
     assert result_with_cache.data["modelName"] == SwitchbotModel.CURTAIN
-    assert result_with_cache.data["modelFriendlyName"] == "Curtain 3"
+    assert result_with_cache.data["modelFriendlyName"] == "Curtain"
     assert result_with_cache.active is False
 
     # Clean up

+ 97 - 2
tests/test_device.py

@@ -10,7 +10,11 @@ import aiohttp
 import pytest
 
 from switchbot import fetch_cloud_devices, fetch_cloud_devices_by_token
-from switchbot.adv_parser import _MODEL_TO_MAC_CACHE, populate_model_to_mac_cache
+from switchbot.adv_parser import (
+    _MODEL_TO_MAC_CACHE,
+    parse_advertisement_data,
+    populate_model_to_mac_cache,
+)
 from switchbot.const import (
     SwitchbotAccountConnectionError,
     SwitchbotApiError,
@@ -18,6 +22,7 @@ from switchbot.const import (
     SwitchbotModel,
 )
 from switchbot.devices.device import (
+    API_MODEL_TO_ENUM,
     SwitchbotBaseDevice,
     SwitchbotDevice,
     SwitchbotEncryptedDevice,
@@ -25,7 +30,97 @@ from switchbot.devices.device import (
     _masked_device_id,
 )
 
-from .test_adv_parser import generate_ble_device
+from .test_adv_parser import generate_advertisement_data, generate_ble_device
+
+
+@pytest.mark.parametrize(
+    ("api_model", "expected_model"),
+    [
+        ("WoCurtain", SwitchbotModel.CURTAIN),
+        ("WoCurtain3", SwitchbotModel.CURTAIN_3),
+        ("WoPlugUS", SwitchbotModel.PLUG_MINI_US),
+        ("WoPlugJP", SwitchbotModel.PLUG_MINI_JP),
+        ("WoMeter", SwitchbotModel.METER),
+        ("WoMeterPlus", SwitchbotModel.METER_PLUS),
+        ("W1079000", SwitchbotModel.METER_PRO),
+        ("W1079001", SwitchbotModel.METER_PRO_CO2),
+        ("WoIOSensor", SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER),
+        ("WoCeiling", SwitchbotModel.CEILING_LIGHT),
+        ("WoCeilingPro", SwitchbotModel.CEILING_LIGHT_PRO),
+        ("WoLinkMini", SwitchbotModel.HUB_MINI),
+        ("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER),
+    ],
+)
+def test_api_model_mapping(api_model: str, expected_model: SwitchbotModel) -> None:
+    """Test API model names map to the exact physical product."""
+    assert API_MODEL_TO_ENUM[api_model] is expected_model
+
+
+def test_legacy_plug_is_not_mapped_to_plug_mini() -> None:
+    """Test the legacy Plug is not identified as a Plug Mini."""
+    assert "WoPlug" not in API_MODEL_TO_ENUM
+    assert SwitchbotModel.PLUG_MINI not in API_MODEL_TO_ENUM.values()
+
+
+def test_deprecated_model_aliases() -> None:
+    """Test old model names remain as compatibility aliases."""
+    assert SwitchbotModel.IO_METER is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
+    assert SwitchbotModel.METER_PRO_C is SwitchbotModel.METER_PRO_CO2
+    assert SwitchbotModel.IO_METER.name == "INDOOR_OUTDOOR_THERMO_HYGROMETER"
+    assert SwitchbotModel.METER_PRO_C.name == "METER_PRO_CO2"
+    assert SwitchbotModel["IO_METER"] is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
+    assert SwitchbotModel["METER_PRO_C"] is SwitchbotModel.METER_PRO_CO2
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("api_model", "expected_model", "can_parse"),
+    [
+        pytest.param("WoLinkMini", SwitchbotModel.HUB_MINI, False, id="ordinary"),
+        pytest.param("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER, True, id="matter"),
+    ],
+)
+async def test_hub_mini_cloud_cache_parsing(
+    mock_user_info: dict[str, Any],
+    api_model: str,
+    expected_model: SwitchbotModel,
+    can_parse: bool,
+) -> None:
+    """Cloud hints distinguish ordinary Hub Mini from Matter during parsing."""
+    mac_address = "E6:A1:CD:1F:5B:65"
+    device_response = {
+        "Items": [
+            {
+                "device_mac": "e6a1cd1f5b65",
+                "device_detail": {"device_type": api_model},
+            }
+        ]
+    }
+    ble_device = generate_ble_device(mac_address)
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00"
+        },
+    )
+
+    with (
+        patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
+        patch.object(
+            SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
+        ),
+        patch.object(SwitchbotBaseDevice, "api_request", return_value=device_response),
+    ):
+        session = MagicMock(spec=aiohttp.ClientSession)
+        devices = await fetch_cloud_devices_by_token(session, "test-token")
+
+        assert devices == {mac_address: expected_model}
+        assert _MODEL_TO_MAC_CACHE[mac_address] is expected_model
+        assert (parse_advertisement_data(ble_device, adv_data) is not None) is can_parse
+
+
+def test_hubmini_matter_model_name() -> None:
+    """Preserve the existing Hub Mini Matter enum name."""
+    assert SwitchbotModel.HUBMINI_MATTER.name == "HUBMINI_MATTER"
 
 
 @pytest.fixture