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

Add support for hub3 (#337)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Retha Runolfsson 4 өдөр өмнө
parent
commit
00f530dc48

+ 14 - 1
switchbot/adv_parser.py

@@ -19,6 +19,7 @@ from .adv_parsers.contact import process_wocontact
 from .adv_parsers.curtain import process_wocurtain
 from .adv_parsers.fan import process_fan
 from .adv_parsers.hub2 import process_wohub2
+from .adv_parsers.hub3 import process_hub3
 from .adv_parsers.hubmini_matter import process_hubmini_matter
 from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
 from .adv_parsers.keypad import process_wokeypad
@@ -57,7 +58,7 @@ class SwitchbotSupportedType(TypedDict):
     manufacturer_data_length: int | None
 
 
-SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
+SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
     "d": {
         "modelName": SwitchbotModel.CONTACT_SENSOR,
         "modelFriendlyName": "Contact Sensor",
@@ -293,6 +294,12 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
         "func": process_air_purifier,
         "manufacturer_id": 2409,
     },
+    b"\x00\x10\xb9\x40": {
+        "modelName": SwitchbotModel.HUB3,
+        "modelFriendlyName": "Hub3",
+        "func": process_hub3,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {
@@ -371,6 +378,12 @@ def _parse_data(
             if model_data.get("manufacturer_data_length") == len(_mfr_data):
                 _model = model_chr
                 break
+    if (
+        _service_data
+        and len(_service_data) > 5
+        and _service_data[-4:] in SUPPORTED_TYPES
+    ):
+        _model = _service_data[-4:]
 
     if not _model:
         return None

+ 56 - 0
switchbot/adv_parsers/hub3.py

@@ -0,0 +1,56 @@
+"""Air Purifier adv parser."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..const.hub3 import LIGHT_INTENSITY_MAP
+
+
+def process_hub3(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
+    """Process hub3 sensor manufacturer data."""
+    if mfr_data is None:
+        return {}
+    device_data = mfr_data[6:]
+
+    seq_num = device_data[0]
+    network_state = (device_data[6] & 0b11000000) >> 6
+    sensor_inserted = not bool(device_data[6] & 0b00100000)
+    light_level = device_data[6] & 0b00001111
+    illuminance = calculate_light_intensity(light_level)
+    temperature_alarm = bool(device_data[7] & 0b11000000)
+    humidity_alarm = bool(device_data[7] & 0b00110000)
+
+    temp_data = device_data[7:10]
+    _temp_sign = 1 if temp_data[1] & 0b10000000 else -1
+    _temp_c = _temp_sign * (
+        (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
+    )
+    _temp_f = round(((_temp_c * 9 / 5) + 32), 1)
+    humidity = temp_data[2] & 0b01111111
+    motion_detected = bool(device_data[10] & 0b10000000)
+
+    return {
+        "sequence_number": seq_num,
+        "network_state": network_state,
+        "sensor_inserted": sensor_inserted,
+        "lightLevel": light_level,
+        "illuminance": illuminance,
+        "temperature_alarm": temperature_alarm,
+        "humidity_alarm": humidity_alarm,
+        "temp": {"c": _temp_c, "f": _temp_f},
+        "temperature": _temp_c,
+        "humidity": humidity,
+        "motion_detected": motion_detected,
+    }
+
+
+def calculate_light_intensity(light_level: int) -> int:
+    """
+    Convert Hub 3 light level (1-10) to actual light intensity value
+    Args:
+        light_level: Integer from 1-10
+    Returns:
+        Corresponding light intensity value or 0 if invalid input
+    """
+    return LIGHT_INTENSITY_MAP.get(max(0, min(light_level, 10)), 0)

+ 1 - 0
switchbot/const/__init__.py

@@ -75,6 +75,7 @@ class SwitchbotModel(StrEnum):
     K10_PRO_COMBO_VACUUM = "K10+ Pro Combo Vacuum"
     AIR_PURIFIER = "Air Purifier"
     AIR_PURIFIER_TABLE = "Air Purifier Table"
+    HUB3 = "Hub3"
 
 
 __all__ = [

+ 18 - 0
switchbot/const/hub3.py

@@ -0,0 +1,18 @@
+"""
+Mapping of light levels to lux measurement values for SwitchBot Hub 3.
+
+Source: After-sales consultation, line chart data provided by switchbot developers
+"""
+
+LIGHT_INTENSITY_MAP = {
+    1: 0,
+    2: 50,
+    3: 90,
+    4: 205,
+    5: 317,
+    6: 510,
+    7: 610,
+    8: 707,
+    9: 801,
+    10: 1023,
+}

+ 100 - 0
tests/test_adv_parser.py

@@ -2842,3 +2842,103 @@ def test_air_purifier_with_empty_data() -> None:
         rssi=-97,
         active=True,
     )
+
+
+def test_hub3_active() -> None:
+    """Test parsing hub3 with active data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: b"\xb0\xe9\xfen^)\x00\xffh&\xd6d\x83\x03\x994\x80"},
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00d\x00\x10\xb9@"
+        },
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"\x00\x00d\x00\x10\xb9@",
+            "data": {
+                "sequence_number": 0,
+                "network_state": 2,
+                "sensor_inserted": True,
+                "lightLevel": 3,
+                "illuminance": 90,
+                "temperature_alarm": False,
+                "humidity_alarm": False,
+                "temp": {"c": 25.3, "f": 77.5},
+                "temperature": 25.3,
+                "humidity": 52,
+                "motion_detected": True,
+            },
+            "isEncrypted": False,
+            "model": b"\x00\x10\xb9@",
+            "modelFriendlyName": "Hub3",
+            "modelName": SwitchbotModel.HUB3,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )
+
+
+def test_hub3_passive() -> None:
+    """Test parsing hub3 with passive data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: b"\xb0\xe9\xfen^)\x00\xffh&\xd6d\x83\x03\x994\x80"},
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.HUB3)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": None,
+            "data": {
+                "sequence_number": 0,
+                "network_state": 2,
+                "sensor_inserted": True,
+                "lightLevel": 3,
+                "illuminance": 90,
+                "temperature_alarm": False,
+                "humidity_alarm": False,
+                "temp": {"c": 25.3, "f": 77.5},
+                "temperature": 25.3,
+                "humidity": 52,
+                "motion_detected": True,
+            },
+            "isEncrypted": False,
+            "model": b"\x00\x10\xb9@",
+            "modelFriendlyName": "Hub3",
+            "modelName": SwitchbotModel.HUB3,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=False,
+    )
+
+
+def test_hub3_with_empty_data() -> None:
+    """Test parsing hub3 with empty data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: None},
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00d\x00\x10\xb9@"
+        },
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"\x00\x00d\x00\x10\xb9@",
+            "data": {},
+            "isEncrypted": False,
+            "model": b"\x00\x10\xb9@",
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )

+ 13 - 0
tests/test_hub3.py

@@ -0,0 +1,13 @@
+from switchbot.adv_parsers.hub3 import calculate_light_intensity
+
+
+def test_calculate_light_intensity():
+    """Test calculating light intensity from Hub 3 light level."""
+    # Test valid inputs
+    assert calculate_light_intensity(1) == 0
+    assert calculate_light_intensity(2) == 50
+    assert calculate_light_intensity(5) == 317
+    assert calculate_light_intensity(10) == 1023
+
+    # Test invalid inputs
+    assert calculate_light_intensity(0) == 0