Skip to content

Commit 00f530d

Browse files
Add support for hub3 (#337)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 29f8294 commit 00f530d

File tree

6 files changed

+202
-1
lines changed

6 files changed

+202
-1
lines changed

switchbot/adv_parser.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .adv_parsers.curtain import process_wocurtain
2020
from .adv_parsers.fan import process_fan
2121
from .adv_parsers.hub2 import process_wohub2
22+
from .adv_parsers.hub3 import process_hub3
2223
from .adv_parsers.hubmini_matter import process_hubmini_matter
2324
from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
2425
from .adv_parsers.keypad import process_wokeypad
@@ -57,7 +58,7 @@ class SwitchbotSupportedType(TypedDict):
5758
manufacturer_data_length: int | None
5859

5960

60-
SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
61+
SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
6162
"d": {
6263
"modelName": SwitchbotModel.CONTACT_SENSOR,
6364
"modelFriendlyName": "Contact Sensor",
@@ -293,6 +294,12 @@ class SwitchbotSupportedType(TypedDict):
293294
"func": process_air_purifier,
294295
"manufacturer_id": 2409,
295296
},
297+
b"\x00\x10\xb9\x40": {
298+
"modelName": SwitchbotModel.HUB3,
299+
"modelFriendlyName": "Hub3",
300+
"func": process_hub3,
301+
"manufacturer_id": 2409,
302+
},
296303
}
297304

298305
_SWITCHBOT_MODEL_TO_CHAR = {
@@ -371,6 +378,12 @@ def _parse_data(
371378
if model_data.get("manufacturer_data_length") == len(_mfr_data):
372379
_model = model_chr
373380
break
381+
if (
382+
_service_data
383+
and len(_service_data) > 5
384+
and _service_data[-4:] in SUPPORTED_TYPES
385+
):
386+
_model = _service_data[-4:]
374387

375388
if not _model:
376389
return None

switchbot/adv_parsers/hub3.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Air Purifier adv parser."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from ..const.hub3 import LIGHT_INTENSITY_MAP
8+
9+
10+
def process_hub3(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
11+
"""Process hub3 sensor manufacturer data."""
12+
if mfr_data is None:
13+
return {}
14+
device_data = mfr_data[6:]
15+
16+
seq_num = device_data[0]
17+
network_state = (device_data[6] & 0b11000000) >> 6
18+
sensor_inserted = not bool(device_data[6] & 0b00100000)
19+
light_level = device_data[6] & 0b00001111
20+
illuminance = calculate_light_intensity(light_level)
21+
temperature_alarm = bool(device_data[7] & 0b11000000)
22+
humidity_alarm = bool(device_data[7] & 0b00110000)
23+
24+
temp_data = device_data[7:10]
25+
_temp_sign = 1 if temp_data[1] & 0b10000000 else -1
26+
_temp_c = _temp_sign * (
27+
(temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
28+
)
29+
_temp_f = round(((_temp_c * 9 / 5) + 32), 1)
30+
humidity = temp_data[2] & 0b01111111
31+
motion_detected = bool(device_data[10] & 0b10000000)
32+
33+
return {
34+
"sequence_number": seq_num,
35+
"network_state": network_state,
36+
"sensor_inserted": sensor_inserted,
37+
"lightLevel": light_level,
38+
"illuminance": illuminance,
39+
"temperature_alarm": temperature_alarm,
40+
"humidity_alarm": humidity_alarm,
41+
"temp": {"c": _temp_c, "f": _temp_f},
42+
"temperature": _temp_c,
43+
"humidity": humidity,
44+
"motion_detected": motion_detected,
45+
}
46+
47+
48+
def calculate_light_intensity(light_level: int) -> int:
49+
"""
50+
Convert Hub 3 light level (1-10) to actual light intensity value
51+
Args:
52+
light_level: Integer from 1-10
53+
Returns:
54+
Corresponding light intensity value or 0 if invalid input
55+
"""
56+
return LIGHT_INTENSITY_MAP.get(max(0, min(light_level, 10)), 0)

switchbot/const/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class SwitchbotModel(StrEnum):
7575
K10_PRO_COMBO_VACUUM = "K10+ Pro Combo Vacuum"
7676
AIR_PURIFIER = "Air Purifier"
7777
AIR_PURIFIER_TABLE = "Air Purifier Table"
78+
HUB3 = "Hub3"
7879

7980

8081
__all__ = [

switchbot/const/hub3.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
Mapping of light levels to lux measurement values for SwitchBot Hub 3.
3+
4+
Source: After-sales consultation, line chart data provided by switchbot developers
5+
"""
6+
7+
LIGHT_INTENSITY_MAP = {
8+
1: 0,
9+
2: 50,
10+
3: 90,
11+
4: 205,
12+
5: 317,
13+
6: 510,
14+
7: 610,
15+
8: 707,
16+
9: 801,
17+
10: 1023,
18+
}

tests/test_adv_parser.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2842,3 +2842,103 @@ def test_air_purifier_with_empty_data() -> None:
28422842
rssi=-97,
28432843
active=True,
28442844
)
2845+
2846+
2847+
def test_hub3_active() -> None:
2848+
"""Test parsing hub3 with active data."""
2849+
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2850+
adv_data = generate_advertisement_data(
2851+
manufacturer_data={2409: b"\xb0\xe9\xfen^)\x00\xffh&\xd6d\x83\x03\x994\x80"},
2852+
service_data={
2853+
"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00d\x00\x10\xb9@"
2854+
},
2855+
rssi=-97,
2856+
)
2857+
result = parse_advertisement_data(ble_device, adv_data)
2858+
assert result == SwitchBotAdvertisement(
2859+
address="aa:bb:cc:dd:ee:ff",
2860+
data={
2861+
"rawAdvData": b"\x00\x00d\x00\x10\xb9@",
2862+
"data": {
2863+
"sequence_number": 0,
2864+
"network_state": 2,
2865+
"sensor_inserted": True,
2866+
"lightLevel": 3,
2867+
"illuminance": 90,
2868+
"temperature_alarm": False,
2869+
"humidity_alarm": False,
2870+
"temp": {"c": 25.3, "f": 77.5},
2871+
"temperature": 25.3,
2872+
"humidity": 52,
2873+
"motion_detected": True,
2874+
},
2875+
"isEncrypted": False,
2876+
"model": b"\x00\x10\xb9@",
2877+
"modelFriendlyName": "Hub3",
2878+
"modelName": SwitchbotModel.HUB3,
2879+
},
2880+
device=ble_device,
2881+
rssi=-97,
2882+
active=True,
2883+
)
2884+
2885+
2886+
def test_hub3_passive() -> None:
2887+
"""Test parsing hub3 with passive data."""
2888+
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2889+
adv_data = generate_advertisement_data(
2890+
manufacturer_data={2409: b"\xb0\xe9\xfen^)\x00\xffh&\xd6d\x83\x03\x994\x80"},
2891+
rssi=-97,
2892+
)
2893+
result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.HUB3)
2894+
assert result == SwitchBotAdvertisement(
2895+
address="aa:bb:cc:dd:ee:ff",
2896+
data={
2897+
"rawAdvData": None,
2898+
"data": {
2899+
"sequence_number": 0,
2900+
"network_state": 2,
2901+
"sensor_inserted": True,
2902+
"lightLevel": 3,
2903+
"illuminance": 90,
2904+
"temperature_alarm": False,
2905+
"humidity_alarm": False,
2906+
"temp": {"c": 25.3, "f": 77.5},
2907+
"temperature": 25.3,
2908+
"humidity": 52,
2909+
"motion_detected": True,
2910+
},
2911+
"isEncrypted": False,
2912+
"model": b"\x00\x10\xb9@",
2913+
"modelFriendlyName": "Hub3",
2914+
"modelName": SwitchbotModel.HUB3,
2915+
},
2916+
device=ble_device,
2917+
rssi=-97,
2918+
active=False,
2919+
)
2920+
2921+
2922+
def test_hub3_with_empty_data() -> None:
2923+
"""Test parsing hub3 with empty data."""
2924+
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2925+
adv_data = generate_advertisement_data(
2926+
manufacturer_data={2409: None},
2927+
service_data={
2928+
"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00d\x00\x10\xb9@"
2929+
},
2930+
rssi=-97,
2931+
)
2932+
result = parse_advertisement_data(ble_device, adv_data)
2933+
assert result == SwitchBotAdvertisement(
2934+
address="aa:bb:cc:dd:ee:ff",
2935+
data={
2936+
"rawAdvData": b"\x00\x00d\x00\x10\xb9@",
2937+
"data": {},
2938+
"isEncrypted": False,
2939+
"model": b"\x00\x10\xb9@",
2940+
},
2941+
device=ble_device,
2942+
rssi=-97,
2943+
active=True,
2944+
)

tests/test_hub3.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from switchbot.adv_parsers.hub3 import calculate_light_intensity
2+
3+
4+
def test_calculate_light_intensity():
5+
"""Test calculating light intensity from Hub 3 light level."""
6+
# Test valid inputs
7+
assert calculate_light_intensity(1) == 0
8+
assert calculate_light_intensity(2) == 50
9+
assert calculate_light_intensity(5) == 317
10+
assert calculate_light_intensity(10) == 1023
11+
12+
# Test invalid inputs
13+
assert calculate_light_intensity(0) == 0

0 commit comments

Comments
 (0)