diff --git a/switchbot/adv_parser.py b/switchbot/adv_parser.py index 7c121029..5bb91d40 100644 --- a/switchbot/adv_parser.py +++ b/switchbot/adv_parser.py @@ -49,6 +49,7 @@ 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 @@ class SwitchbotSupportedType(TypedDict): "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( diff --git a/switchbot/adv_parsers/weather_station.py b/switchbot/adv_parsers/weather_station.py new file mode 100644 index 00000000..28932b1b --- /dev/null +++ b/switchbot/adv_parsers/weather_station.py @@ -0,0 +1,57 @@ +"""Weather Station parser.""" + +from __future__ import annotations + +from typing import Any + +from ..helpers import celsius_to_fahrenheit + + +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: + if not temp_data: + temp_data = data[3:6] + if battery is None: + battery = data[2] & 0b01111111 + + 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 { + "temp": {"c": _temp_c, "f": _temp_f}, + "temperature": _temp_c, + "fahrenheit": bool(temp_data[2] & 0b10000000), + "humidity": humidity, + "battery": battery, + } diff --git a/switchbot/const/__init__.py b/switchbot/const/__init__.py index 4e6e2512..4a95ac5d 100644 --- a/switchbot/const/__init__.py +++ b/switchbot/const/__init__.py @@ -108,6 +108,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__ = [ diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index c71c3b30..3e944ecf 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -4424,6 +4424,124 @@ 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_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")