Browse Source

Add Weather Station device support (#460)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
7eaves 1 day ago
parent
commit
a29682410d

+ 13 - 0
switchbot/adv_parser.py

@@ -49,6 +49,7 @@ from .adv_parsers.remote import process_woremote
 from .adv_parsers.roller_shade import process_worollershade
 from .adv_parsers.smart_thermostat_radiator import process_smart_thermostat_radiator
 from .adv_parsers.vacuum import process_vacuum, process_vacuum_k
+from .adv_parsers.weather_station import process_weather_station
 from .const import SwitchbotModel
 from .models import SwitchBotAdvertisement
 from .utils import format_mac_upper
@@ -791,6 +792,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_wolock_pro,
         "manufacturer_id": 2409,
     },
+    b"\x00\x10\x53\xb0": {
+        "modelName": SwitchbotModel.WEATHER_STATION,
+        "modelFriendlyName": "Weather Station",
+        "func": process_weather_station,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x10\x53\xb0": {
+        "modelName": SwitchbotModel.WEATHER_STATION,
+        "modelFriendlyName": "Weather Station",
+        "func": process_weather_station,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR: defaultdict[SwitchbotModel, list[str | bytes]] = defaultdict(

+ 36 - 0
switchbot/adv_parsers/_sensor_th.py

@@ -0,0 +1,36 @@
+"""Shared temperature/humidity decoding helpers for T/H sensors."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..helpers import celsius_to_fahrenheit
+
+
+def decode_temp_humidity(temp_data: bytes, battery: int | None) -> dict[str, Any]:
+    """
+    Decode temperature/humidity/fahrenheit-flag from a 3-byte payload.
+
+    Layout (bytes after company ID, for SwitchBot T/H sensors):
+        byte 0: bits[3:0] = temperature decimal (0.1 °C units)
+        byte 1: bit[7] = temperature sign (1 = positive), bits[6:0] = integer °C
+        byte 2: bit[7] = fahrenheit-display flag, bits[6:0] = humidity %
+    """
+    _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 = celsius_to_fahrenheit(_temp_c)
+    _temp_f = (_temp_f * 10) / 10
+    humidity = temp_data[2] & 0b01111111
+
+    if _temp_c == 0 and humidity == 0 and battery == 0:
+        return {}
+
+    return {
+        "temp": {"c": _temp_c, "f": _temp_f},
+        "temperature": _temp_c,
+        "fahrenheit": bool(temp_data[2] & 0b10000000),
+        "humidity": humidity,
+        "battery": battery,
+    }

+ 3 - 21
switchbot/adv_parsers/meter.py

@@ -5,7 +5,7 @@ from __future__ import annotations
 import struct
 from typing import Any
 
-from ..helpers import celsius_to_fahrenheit
+from ._sensor_th import decode_temp_humidity
 
 CO2_UNPACK = struct.Struct(">H").unpack_from
 
@@ -13,7 +13,7 @@ CO2_UNPACK = struct.Struct(">H").unpack_from
 def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
     """Process woSensorTH/Temp sensor services data."""
     temp_data: bytes | None = None
-    battery: bytes | None = None
+    battery: int | None = None
 
     if mfr_data:
         temp_data = mfr_data[8:11]
@@ -26,25 +26,7 @@ def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str,
     if not temp_data:
         return {}
 
-    _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 = celsius_to_fahrenheit(_temp_c)
-    _temp_f = (_temp_f * 10) / 10
-    humidity = temp_data[2] & 0b01111111
-
-    if _temp_c == 0 and humidity == 0 and battery == 0:
-        return {}
-
-    return {
-        # Data should be flat, but we keep the original structure for now
-        "temp": {"c": _temp_c, "f": _temp_f},
-        "temperature": _temp_c,
-        "fahrenheit": bool(temp_data[2] & 0b10000000),
-        "humidity": humidity,
-        "battery": battery,
-    }
+    return decode_temp_humidity(temp_data, battery)
 
 
 def process_wosensorth_c(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:

+ 40 - 0
switchbot/adv_parsers/weather_station.py

@@ -0,0 +1,40 @@
+"""Weather Station parser."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ._sensor_th import decode_temp_humidity
+
+
+def process_weather_station(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, Any]:
+    """
+    Process Weather Station advertisement data.
+
+    Manufacturer data layout (mfr_id=2409, after company ID stripped by bleak):
+        Byte 0-5: MAC address
+        Byte 6:   Sequence number
+        Byte 7:   Battery (bit7=charging, bit6-0=level%)
+        Byte 8:   Temp alarm(bit7-6), Humidity alarm(bit5-4), Temp decimal(bit3-0)
+        Byte 9:   Temp sign(bit7: 0=neg,1=pos), Temp integer(bit6-0)
+        Byte 10:  Fahrenheit flag(bit7), Humidity(bit6-0)
+    """
+    temp_data: bytes | None = None
+    battery: int | None = None
+
+    if mfr_data and len(mfr_data) >= 11:
+        temp_data = mfr_data[8:11]
+        battery = mfr_data[7] & 0b01111111
+
+    if data and len(data) >= 6:
+        if not temp_data:
+            temp_data = data[3:6]
+        if battery is None:
+            battery = data[2] & 0b01111111
+
+    if not temp_data:
+        return {}
+
+    return decode_temp_humidity(temp_data, battery)

+ 1 - 0
switchbot/const/__init__.py

@@ -110,6 +110,7 @@ class SwitchbotModel(StrEnum):
     LOCK_VISION_PRO = "Lock Vision Pro"
     LOCK_VISION = "Lock Vision"
     LOCK_PRO_WIFI = "Lock Pro Wifi"
+    WEATHER_STATION = "Weather Station"
 
 
 __all__ = [

+ 132 - 0
tests/test_adv_parser.py

@@ -4430,6 +4430,138 @@ def test_with_invalid_advertisement(manufacturer_data, service_data, model) -> N
     assert result is None
 
 
+def test_weather_station_active() -> None:
+    """Test Weather Station active advertisement."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xaa\xbb\xcc\xdd\xee\xff\x01\x50\x06\x9a\x23\x00\x00\x00\x00\x00"
+        },
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x50\x00\x10\x53\xb0"
+        },
+        rssi=-67,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "data": {
+                "battery": 80,
+                "fahrenheit": False,
+                "humidity": 35,
+                "temp": {"c": 26.6, "f": 79.88},
+                "temperature": 26.6,
+            },
+            "isEncrypted": False,
+            "model": b"\x00\x10\x53\xb0",
+            "modelFriendlyName": "Weather Station",
+            "modelName": SwitchbotModel.WEATHER_STATION,
+            "rawAdvData": b"\x00\x00\x50\x00\x10\x53\xb0",
+        },
+        device=ble_device,
+        rssi=-67,
+        active=True,
+    )
+
+
+def test_weather_station_passive() -> None:
+    """Test Weather Station passive advertisement."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xaa\xbb\xcc\xdd\xee\xff\x01\x50\x06\x9a\x23\x00\x00\x00\x00\x00"
+        },
+        rssi=-67,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.WEATHER_STATION
+    )
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "data": {
+                "battery": 80,
+                "fahrenheit": False,
+                "humidity": 35,
+                "temp": {"c": 26.6, "f": 79.88},
+                "temperature": 26.6,
+            },
+            "isEncrypted": False,
+            "model": b"\x00\x10\x53\xb0",
+            "modelFriendlyName": "Weather Station",
+            "modelName": SwitchbotModel.WEATHER_STATION,
+            "rawAdvData": None,
+        },
+        device=ble_device,
+        rssi=-67,
+        active=False,
+    )
+
+
+def test_weather_station_service_data_only() -> None:
+    """Test Weather Station with service data only (no manufacturer data)."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x50\x06\x9a\x23\x00\x10\x53\xb0"
+        },
+        rssi=-67,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.WEATHER_STATION
+    )
+    assert result is not None
+    assert result.data["data"]["temperature"] == 26.6
+    assert result.data["data"]["humidity"] == 35
+    assert result.data["data"]["battery"] == 80
+
+
+def test_weather_station_empty_data() -> None:
+    """Test Weather Station with empty/zero data returns empty dict."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xaa\xbb\xcc\xdd\xee\xff\x01\x00\x00\x80\x00\x00\x00\x00\x00\x00"
+        },
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\x53\xb0"
+        },
+        rssi=-67,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result is not None
+    assert result.data["data"] == {}
+
+
+def test_weather_station_short_service_data() -> None:
+    """Test Weather Station with too-short service data does not raise IndexError."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x01"},
+        rssi=-67,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.WEATHER_STATION
+    )
+    assert result is not None
+    assert result.data["data"] == {}
+
+
+def test_weather_station_no_data() -> None:
+    """Test Weather Station with no usable data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: b"\xaa\xbb\xcc\xdd\xee"},
+        rssi=-67,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.WEATHER_STATION
+    )
+    assert result is not None
+    assert result.data["data"] == {}
+
+
 def test_with_special_manufacturer_data_length() -> None:
     """Test with special manufacturer data length."""
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")