From c0e9ef0a60ec38abdf05c919181145608b72818c Mon Sep 17 00:00:00 2001 From: Fan Kai Date: Fri, 13 Mar 2026 16:43:29 +0800 Subject: [PATCH 1/3] feat: add Weather Station device support Add BLE advertisement parser and device registration for the SwitchBot Weather Station. Parses temperature, humidity, and battery from broadcast data. Includes unit tests for active and passive advertisement parsing. Co-Authored-By: Claude Opus 4.5 --- switchbot/adv_parser.py | 13 +++++ switchbot/adv_parsers/weather_station.py | 56 +++++++++++++++++++ switchbot/const/__init__.py | 1 + switchbot/devices/weather_station.py | 3 ++ tests/test_adv_parser.py | 69 ++++++++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 switchbot/adv_parsers/weather_station.py create mode 100644 switchbot/devices/weather_station.py 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..59229dc2 --- /dev/null +++ b/switchbot/adv_parsers/weather_station.py @@ -0,0 +1,56 @@ +"""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/switchbot/devices/weather_station.py b/switchbot/devices/weather_station.py new file mode 100644 index 00000000..00d60c97 --- /dev/null +++ b/switchbot/devices/weather_station.py @@ -0,0 +1,3 @@ +"""Library to handle connection with SwitchBot Weather Station.""" + +from __future__ import annotations diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index c71c3b30..6a3ce8ba 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -4424,6 +4424,75 @@ 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_with_special_manufacturer_data_length() -> None: """Test with special manufacturer data length.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") From 3422bb6dddecf557308fe80efda3cd559b232a0c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:45:08 +0000 Subject: [PATCH 2/3] chore(pre-commit.ci): auto fixes --- switchbot/adv_parsers/weather_station.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/switchbot/adv_parsers/weather_station.py b/switchbot/adv_parsers/weather_station.py index 59229dc2..28932b1b 100644 --- a/switchbot/adv_parsers/weather_station.py +++ b/switchbot/adv_parsers/weather_station.py @@ -10,7 +10,8 @@ def process_weather_station( data: bytes | None, mfr_data: bytes | None ) -> dict[str, Any]: - """Process Weather Station advertisement data. + """ + Process Weather Station advertisement data. Manufacturer data layout (mfr_id=2409, after company ID stripped by bleak): Byte 0-5: MAC address From a1e6f4d8d0e61a2bacf8622335afaf7807abf9b0 Mon Sep 17 00:00:00 2001 From: Fan Kai Date: Fri, 13 Mar 2026 16:57:13 +0800 Subject: [PATCH 3/3] test: improve Weather Station test coverage to 100% Add tests for service-data-only path, zero-data guard, and short manufacturer data. Remove unused devices/weather_station.py stub. Co-Authored-By: Claude Opus 4.5 --- switchbot/devices/weather_station.py | 3 -- tests/test_adv_parser.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) delete mode 100644 switchbot/devices/weather_station.py diff --git a/switchbot/devices/weather_station.py b/switchbot/devices/weather_station.py deleted file mode 100644 index 00d60c97..00000000 --- a/switchbot/devices/weather_station.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Library to handle connection with SwitchBot Weather Station.""" - -from __future__ import annotations diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index 6a3ce8ba..3e944ecf 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -4493,6 +4493,55 @@ def test_weather_station_passive() -> None: ) +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")