Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -79,6 +84,7 @@
"AirQualityLevel",
"BulbColorMode",
"CeilingLightColorMode",
"CirculatorFanProMode",
"ClimateAction",
"ClimateMode",
"ColorMode",
Expand Down Expand Up @@ -106,6 +112,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 @@ -690,6 +694,18 @@ class SwitchbotSupportedType(TypedDict):
"func": process_rgbicww_ceiling_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 @@ -60,3 +63,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, with_charging=True)


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 @@ -133,6 +135,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 @@ -103,6 +103,7 @@ def _extract_region(userinfo: dict[str, Any]) -> str:
"W1102003": SwitchbotModel.RGBICWW_STRIP_LIGHT,
"W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP,
"W1162000": SwitchbotModel.RGBICWW_CEILING_LIGHT,
"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 @@ -186,6 +191,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 @@ -305,3 +315,104 @@ def get_sound(self) -> bool | None:
def get_auto_recenter(self) -> bool | None:
"""Return cached auto-recenter (return-to-center) state."""
return self._get_adv_value("auto_recenter")


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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bluetoothbot gh_request

Didn't we get rid of the need to have these init stubs

**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})
Comment on lines +401 to +410

@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