Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion switchbot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AirQualityLevel,
BulbColorMode,
CeilingLightColorMode,
CirculatorFanProMode,
ClimateAction,
ClimateMode,
ColorMode,
Expand Down Expand Up @@ -48,7 +49,11 @@
fetch_cloud_devices,
)
from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
from .devices.fan import SwitchbotFan, SwitchbotStandingFan
from .devices.fan import (
SwitchbotCirculatorFanPro,
SwitchbotFan,
SwitchbotStandingFan,
)
from .devices.humidifier import SwitchbotHumidifier
from .devices.keypad_vision import SwitchbotKeypadVision
from .devices.light_strip import (
Expand Down Expand Up @@ -78,6 +83,7 @@
"AirQualityLevel",
"BulbColorMode",
"CeilingLightColorMode",
"CirculatorFanProMode",
"ClimateAction",
"ClimateMode",
"ColorMode",
Expand Down Expand Up @@ -105,6 +111,7 @@
"SwitchbotBulb",
"SwitchbotCandleWarmerLamp",
"SwitchbotCeilingLight",
"SwitchbotCirculatorFanPro",
"SwitchbotCurtain",
"SwitchbotDevice",
"SwitchbotEncryptedDevice",
Expand Down
18 changes: 17 additions & 1 deletion switchbot/adv_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
from .adv_parsers.climate_panel import process_climate_panel
from .adv_parsers.contact import process_wocontact
from .adv_parsers.curtain import process_wocurtain
from .adv_parsers.fan import process_fan, process_standing_fan
from .adv_parsers.fan import (
process_circulator_fan_pro,
process_fan,
process_standing_fan,
)
from .adv_parsers.hub2 import process_wohub2
from .adv_parsers.hub3 import process_hub3
from .adv_parsers.hubmini_matter import process_hubmini_matter
Expand Down Expand Up @@ -677,6 +681,18 @@ class SwitchbotSupportedType(TypedDict):
"func": process_rgbic_light,
"manufacturer_id": 2409,
},
b"\x00\x11\xb3@": {
"modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
"modelFriendlyName": "Circulator Fan Pro",
"func": process_circulator_fan_pro,
"manufacturer_id": 2409,
},
b"\x01\x11\xb3@": {
"modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
"modelFriendlyName": "Circulator Fan Pro",
"func": process_circulator_fan_pro,
"manufacturer_id": 2409,
},
b"\x00\x10\xd0\xb7": {
"modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
"modelFriendlyName": "Permanent Outdoor Light",
Expand Down
50 changes: 49 additions & 1 deletion switchbot/adv_parsers/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

from __future__ import annotations

from ..const.fan import FanMode, StandingFanMode
from ..const.fan import CirculatorFanProMode, FanMode, StandingFanMode

_FAN_MODE_MAP: dict[int, str] = {m.value: m.name.lower() for m in FanMode}
_STANDING_FAN_MODE_MAP: dict[int, str] = {
m.value: m.name.lower() for m in StandingFanMode
}
_CIRCULATOR_FAN_PRO_MODE_MAP: dict[int, str] = {
m.value: m.name.lower() for m in CirculatorFanProMode
}


def _parse_fan(
Expand Down Expand Up @@ -53,3 +56,48 @@ def process_standing_fan(
) -> dict[str, bool | int | str | None]:
"""Process Standing Fan services data (modes 1-5; adds CUSTOM_NATURAL)."""
return _parse_fan(mfr_data, _STANDING_FAN_MODE_MAP)


def process_circulator_fan_pro(
data: bytes | None, mfr_data: bytes | None
) -> dict[str, bool | int | str | None]:
"""
Process Circulator Fan Pro (W1160) advertisement.

The Pro shares the W1071 Modern Ceiling Fan broadcast layout, which differs
from the legacy Circulator Fan: battery and the fan-state byte are swapped.
The fan-state byte carries a two-level night light (bit2 = on/off, bit3 =
level: 0 high / 1 low). Byte offsets are relative to the manufacturer data,
after the leading 6-byte MAC.
"""
if mfr_data is None or len(mfr_data) < 10:
return {}

device_data = mfr_data[6:]

_seq_num = device_data[0]
_charging = bool(device_data[1] & 0b10000000)
_battery = device_data[1] & 0b01111111
_state = device_data[2]
_isOn = bool(_state & 0b10000000)
_mode = (_state & 0b01110000) >> 4
_night_light_on = bool(_state & 0b00000100)
# bit3: 0 = level 1 (high / bright), 1 = level 2 (low / dim)
_night_light_level = 2 if _state & 0b00001000 else 1
_oscillate_left_and_right = bool(_state & 0b00000010)
_oscillate_up_and_down = bool(_state & 0b00000001)
_speed = device_data[3] & 0b01111111

return {
"sequence_number": _seq_num,
"isOn": _isOn,
"mode": _CIRCULATOR_FAN_PRO_MODE_MAP.get(_mode),
"night_light_is_on": _night_light_on,
"night_light_level": _night_light_level if _night_light_on else 0,
"oscillating": _oscillate_left_and_right or _oscillate_up_and_down,
"oscillating_horizontal": _oscillate_left_and_right,
"oscillating_vertical": _oscillate_up_and_down,
"battery": _battery,
"charging": _charging,
"speed": _speed,
}
3 changes: 3 additions & 0 deletions switchbot/const/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
HumidifierWaterLevel,
)
from .fan import (
CirculatorFanProMode,
FanMode,
HorizontalOscillationAngle,
NightLightState,
Expand Down Expand Up @@ -86,6 +87,7 @@ class SwitchbotModel(StrEnum):
ROLLER_SHADE = "Roller Shade"
HUBMINI_MATTER = "HubMini Matter"
CIRCULATOR_FAN = "Circulator Fan"
CIRCULATOR_FAN_PRO = "Circulator Fan Pro"
STANDING_FAN = "Standing Fan"
K20_VACUUM = "K20 Vacuum"
S10_VACUUM = "S10 Vacuum"
Expand Down Expand Up @@ -132,6 +134,7 @@ class SwitchbotModel(StrEnum):
"AirQualityLevel",
"BulbColorMode",
"CeilingLightColorMode",
"CirculatorFanProMode",
"ClimateAction",
"ClimateMode",
"ColorMode",
Expand Down
17 changes: 17 additions & 0 deletions switchbot/const/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ def get_modes(cls) -> list[str]:
return [mode.name.lower() for mode in cls]


class CirculatorFanProMode(Enum):
"""
Circulator Fan Pro (W1160) running modes.

Mode 0x04 is hurricane (飓风), not the baby mode of the legacy fan.
"""

NORMAL = 1
NATURAL = 2
SLEEP = 3
HURRICANE = 4

@classmethod
def get_modes(cls) -> list[str]:
return [mode.name.lower() for mode in cls]


class NightLightState(Enum):
"""Standing Fan night-light command values."""

Expand Down
1 change: 1 addition & 0 deletions switchbot/devices/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def _extract_region(userinfo: dict[str, Any]) -> str:
"W1102001": SwitchbotModel.STRIP_LIGHT_3,
"W1102003": SwitchbotModel.RGBICWW_STRIP_LIGHT,
"W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP,
"W1160000": SwitchbotModel.CIRCULATOR_FAN_PRO,
"W1104000": SwitchbotModel.PLUG_MINI_EU,
"W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
"W1111000": SwitchbotModel.CLIMATE_PANEL,
Expand Down
111 changes: 111 additions & 0 deletions switchbot/devices/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
from enum import Enum
from typing import Any, ClassVar

from bleak.backends.device import BLEDevice

from ..const import SwitchbotModel
from ..const.fan import (
CirculatorFanProMode,
FanMode,
HorizontalOscillationAngle,
NightLightState,
Expand All @@ -15,6 +19,7 @@
)
from .device import (
DEVICE_GET_BASIC_SETTINGS_KEY,
SwitchbotEncryptedDevice,
SwitchbotSequenceDevice,
update_after_operation,
)
Expand Down Expand Up @@ -177,6 +182,11 @@ def get_current_mode(self) -> Any:
"""Return cached mode."""
return self._get_adv_value("mode")

@property
def fan_modes(self) -> list[str]:
"""Return the supported preset (wind) modes for this device."""
return self._mode_enum.get_modes()


class SwitchbotStandingFan(SwitchbotFan):
"""Representation of a Switchbot Standing Fan (FAN2)."""
Expand Down Expand Up @@ -224,3 +234,104 @@ async def set_night_light(self, state: NightLightState | int) -> bool:
def get_night_light_state(self) -> int | None:
"""Return cached night light state."""
return self._get_adv_value("nightLight")


class SwitchbotCirculatorFanPro(SwitchbotEncryptedDevice, SwitchbotFan):
"""
Representation of a Switchbot Circulator Fan Pro (W1160).

The Pro uses extended commands (``57 0F <subcmd> …``) with a control-source
byte (0x29 = Home Assistant), wrapped in the encrypted command shell, so it
extends SwitchbotEncryptedDevice. Fan power uses subcommand 0x41 (open/close
sub-op 0x11); the night light uses subcommand 0x96. The night light command
is on/off only (the device exposes two read-only brightness levels in its
advertisement, but they are not separately settable here).
"""

_model = SwitchbotModel.CIRCULATOR_FAN_PRO

# Fan power: ext 0x0F, subcmd 0x41, 0x11 = 开关机, 0x29 = control source
# (Home Assistant), byte5 0x01 = on / 0x00 = off / 0x02 = toggle.
_turn_on_command = "570f41112901"
_turn_off_command = "570f41112900"
# Preset mode: the 0x11 power command also carries the running mode in byte6
# (turning the fan on). The Pro's mode 0x04 is hurricane, not the legacy baby.
_mode_enum: ClassVar[type[Enum]] = CirculatorFanProMode
_command_set_mode: ClassVar[dict[str, str]] = {
mode.name.lower(): f"570f41112901{mode.value:02X}"
for mode in CirculatorFanProMode
}
# Night light: ext 0x0F, subcmd 0x96, byte3 0x0A, byte4 0x02, then a state
# byte: bit0 = on/off (0 off / 1 on), bit1 = level (0 high / 1 low).
# off = 0x00, on high = 0x01, on low = 0x03.
_night_light_command = "570f960a02{}"

def __init__(
self,
device: BLEDevice,
key_id: str,
encryption_key: str,
interface: int = 0,
model: SwitchbotModel = SwitchbotModel.CIRCULATOR_FAN_PRO,
**kwargs: Any,
) -> None:
"""Initialize the Circulator Fan Pro."""
super().__init__(device, key_id, encryption_key, interface, model, **kwargs)

async def get_basic_info(self) -> dict[str, Any] | None:
"""
Get device basic info.

The Pro carries all runtime state (fan + night light) in its
advertisement, and its GATT basic-info responses differ from the
legacy Circulator Fan, so parse only the firmware here and do so
defensively.
"""
if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)):
return None
if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
return None

_LOGGER.debug(
"Circulator Fan Pro basic info: data=%s data1=%s",
_data.hex(),
_data1.hex(),
)
info: dict[str, Any] = {}
if len(_data1) > 2:
info["firmware"] = _data1[2] / 10.0
return info

@update_after_operation
async def set_percentage(self, percentage: int) -> bool:
"""
Set the fan speed (1-100).

Speed lives in byte7 of the 0x11 power command and only applies in
direct mode, so this sends "on + direct mode + speed".
"""
result = await self._send_command(f"570f4111290101{percentage:02X}")
return self._check_command_result(result, 0, {1})

@update_after_operation
async def turn_on_light(self, low: bool = False) -> bool:
"""Turn the night light on (low selects level 2 / dim, else level 1 / bright)."""
state = 0x03 if low else 0x01
result = await self._send_command(
self._night_light_command.format(f"{state:02X}")
)
return self._check_command_result(result, 0, {1})

@update_after_operation
async def turn_off_light(self) -> bool:
"""Turn the night light off."""
result = await self._send_command(self._night_light_command.format("00"))
return self._check_command_result(result, 0, {1})

def is_night_light_on(self) -> bool | None:
"""Return the cached night-light power state."""
return self._get_adv_value("night_light_is_on")

def get_night_light_level(self) -> int | None:
"""Return the cached night-light level (1 high, 2 low, 0 off)."""
return self._get_adv_value("night_light_level")
Loading
Loading