Skip to content

feat: add Circulator Fan Pro (W1160) support - #508

Open
Onero-testdev wants to merge 6 commits into
sblibs:mainfrom
Onero-testdev:feature/circulator-fan-pro
Open

feat: add Circulator Fan Pro (W1160) support#508
Onero-testdev wants to merge 6 commits into
sblibs:mainfrom
Onero-testdev:feature/circulator-fan-pro

Conversation

@Onero-testdev

@Onero-testdev Onero-testdev commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for the SwitchBot Circulator Fan Pro (W1160) as an encrypted BLE device.

The Pro shares the W1071 Modern Ceiling Fan advertisement layout (which differs from the legacy Circulator Fan — battery and the fan-state byte are swapped), and uses extended commands (57 0F …) with a control-source byte for control.

Features

  • Fan: power on/off, speed (1–100), preset modes (direct / natural / sleep / hurricane), horizontal + vertical oscillation
  • Night light: two-level (high / low) on/off
  • State: parsed from the advertisement (fan on/off, mode, speed, oscillation per-axis, battery, charging, night-light state)

Implementation

  • New SwitchbotModel.CIRCULATOR_FAN_PRO (W1160000) + adv signatures b"\x00\x11\xb3@" / b"\x01\x11\xb3@"
  • New process_circulator_fan_pro advertisement parser
  • New SwitchbotCirculatorFanPro device (SwitchbotEncryptedDevice + SwitchbotFan)
  • New CirculatorFanProMode enum (mode 0x04 is hurricane, not the legacy baby)

All command frames and advertisement parsing were verified against real W1160 hardware.

🤖 Generated with Claude Code

Note from SwitchBot team

This change is developed by the official SwitchBot team. The implementation has been verified with real hardware testing on physical devices.

If you have any questions or need clarification, please reach out:

@codecov

codecov Bot commented Jun 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
switchbot/__init__.py 100.00% <100.00%> (ø)
switchbot/adv_parser.py 97.82% <100.00%> (ø)
switchbot/adv_parsers/fan.py 100.00% <100.00%> (ø)
switchbot/const/__init__.py 100.00% <100.00%> (ø)
switchbot/const/fan.py 100.00% <100.00%> (ø)
switchbot/devices/device.py 69.90% <ø> (ø)
switchbot/devices/fan.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bdraco bdraco closed this Jun 2, 2026
@bdraco

bdraco commented Jun 2, 2026

Copy link
Copy Markdown
Member

Waiting for a PR for switchbot to add this

@Onero-testdev

Copy link
Copy Markdown
Contributor Author

@bdraco Hello, this account is being used by a developer from Switchbot. I don't quite understand what you mean by "Waiting for a PR for Switchbot to add this." I hope you can provide more information. Thanks!

@Onero-testdev

Copy link
Copy Markdown
Contributor Author

Opened the Home Assistant core PR that consumes this: home-assistant/core#173262 (adds the Circulator Fan Pro to the switchbot integration). Draft for now — the manifest still pins PySwitchbot==2.2.0, so its CI will stay red until a release containing SwitchbotCirculatorFanPro is published. Let me know how you'd like to sequence the library release and I'll bump the pin + mark it ready.

@zerzhang zerzhang reopened this Jun 9, 2026
@bluetoothbot

bluetoothbot commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Previous review — superseded by a newer review below.

bluetoothbot
bluetoothbot previously approved these changes Jun 20, 2026

@bluetoothbot bluetoothbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking issues found.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds SwitchBot Circulator Fan Pro (W1160) support to the library by introducing a new encrypted fan device class and advertisement parsing so the device can be discovered and controlled over BLE.

Changes:

  • Added Circulator Fan Pro model/exports/constants and API model mapping (W1160000).
  • Added W1160 advertisement routing + parser for its modern fan broadcast layout.
  • Added SwitchbotCirculatorFanPro device implementation plus dedicated unit tests for parsing and commands.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_fan.py Adds unit tests and helpers for Circulator Fan Pro commands/state getters.
tests/test_adv_parser.py Adds advertisement parsing tests and model routing verification for W1160.
switchbot/devices/fan.py Introduces SwitchbotCirculatorFanPro and a fan_modes convenience property.
switchbot/devices/device.py Maps SwitchBot API model string W1160000 to the new enum model.
switchbot/const/fan.py Adds CirculatorFanProMode enum.
switchbot/const/__init__.py Exports CirculatorFanProMode and adds CIRCULATOR_FAN_PRO to SwitchbotModel.
switchbot/adv_parsers/fan.py Adds process_circulator_fan_pro advertisement parser.
switchbot/adv_parser.py Adds W1160 service-data suffix signatures to route to the new parser.
switchbot/__init__.py Exports SwitchbotCirculatorFanPro and CirculatorFanProMode.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread switchbot/devices/fan.py
Comment on lines +306 to +314
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 thread switchbot/devices/fan.py Outdated
Comment on lines +243 to +248
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).
Comment thread tests/test_fan.py
Comment on lines +387 to +400
def create_circulator_fan_pro_for_testing(init_data: dict | None = None):
"""Create an encrypted SwitchbotCirculatorFanPro instance for testing."""
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
fan_device = SwitchbotCirculatorFanPro(
ble_device,
"ff",
"ffffffffffffffffffffffffffffffff",
model=SwitchbotModel.CIRCULATOR_FAN_PRO,
)
fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
fan_device._send_command = AsyncMock()
fan_device._check_command_result = MagicMock()
fan_device.update = AsyncMock()
return fan_device
Onero-testdev and others added 3 commits June 25, 2026 16:40
Add the SwitchBot Circulator Fan Pro (W1160) as an encrypted BLE device:
fan power/speed/preset-mode (direct/natural/sleep/hurricane), horizontal and
vertical oscillation, and a two-level night light. State is parsed from the
W1071-style advertisement (battery/fan-state swapped vs the legacy fan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bluetoothbot

bluetoothbot commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Previous review — superseded by a newer review below.

@bluetoothbot bluetoothbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking issues found.

  • Oscillation commands inherited from legacy fan lack the Pro's control-source byte and are untested

…t_percentage, fix test fixture

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Onero-testdev

Copy link
Copy Markdown
Contributor Author

The issue flagged by @bluetoothbot — oscillation commands missing the Pro control-source byte — has been fixed in commit 34820d6.

What changed:

  • set_oscillation() and set_vertical_oscillation() now send the Pro control-source byte (0x01 for app-controlled, 0x02 for auto) as the first byte of the command payload, matching the Circulator Fan Pro protocol.
  • set_percentage() now validates the input range (0–100).
  • Test fixture updated to cover the new oscillation command format.

CI: All green (tests pass on 3.11–3.14, lint passes, pre-commit.ci passes).

@bdraco could you help trigger @bluetoothbot review? (We don't have write access to trigger bot commands directly.)

@bluetoothbot

Copy link
Copy Markdown
Collaborator

@Onero-testdev: The issue flagged by @bluetoothbot — oscillation commands missing the Pro control-source byte — has ...

Verified against diff. Oscillation frames now carry 0x29 on all six paths: both-axes 570f4102290101 / 570f4102290202, horizontal 570f41022901ff / 570f41022902ff, vertical 570f410229ff01 / 570f410229ff02. Tests assert each byte-for-byte. Finding closed.

One wording mismatch: comment says control source "0x01 app-controlled, 0x02 auto". Shipped code uses 0x29, per # 0x29 = control source (Home Assistant). Code correct; comment text stale.

Note also: base set_horizontal_oscillation / set_vertical_oscillation read module-level constants, not ClassVars. Overriding both methods was required — ClassVars alone would not have taken effect. Correct as written.

@bluetoothbot

Copy link
Copy Markdown
Collaborator

@Onero-testdev: Opened the Home Assistant core PR that consumes this: home-assistant/core#173262 (adds the Circulato...

No manual release step needed. Merge to main triggers python-semantic-release in ci.yml. feat: commit yields minor bump, tags, publishes PyPI automatically.

So sequencing: land here first, wait for PyPI publish, then bump manifest pin and mark HA PR ready.

@bluetoothbot

bluetoothbot commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Previous review — superseded by a newer review below.

@bluetoothbot bluetoothbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tip

No blocking issues found — ready to merge.

@Onero-testdev

Copy link
Copy Markdown
Contributor Author

All checks are green and the latest automated review confirms the previous blocking finding is resolved and the PR is merge-ready. This merge and automatic release will unblock Home Assistant Core PR home-assistant/core#173262. Correction to my earlier summary: the implemented Home Assistant control-source byte is 0x29, as the code and tests correctly show. @bdraco, could you approve/merge this when convenient? Thanks!

@bluetoothbot

Copy link
Copy Markdown
Collaborator

Note

No code changes since the last review (34820d6) — skipping. Push new commits or re-request a review to run again.

@bdraco

bdraco commented Aug 19, 2026

Copy link
Copy Markdown
Member

Bot left some suggestion, but still needs human review, and out of time.

@bdraco

bdraco commented Aug 19, 2026

Copy link
Copy Markdown
Member

Bot left some suggestion, but still needs human review, and out of time.

@bluetoothbot

Copy link
Copy Markdown
Collaborator

@Onero-testdev: @bdraco Hello, this account is being used by a developer from Switchbot. I don't quite understand wh...

Means library support lands first. Home Assistant core cannot import SwitchbotCirculatorFanPro until pySwitchbot publishes it. This PR provides that layer. home-assistant/core#173262 unblocks after merge plus semantic-release publish.

@bluetoothbot

Copy link
Copy Markdown
Collaborator

@bdraco: Bot left some suggestion, but still needs human review, and out of time.

Understood. No blocking findings remain. Three non-blocking items for a human pass: redundant second GATT read in get_basic_info (fan.py:376), night-light surface versus existing NightLightState (fan.py:434), set_percentage(0) powering on plus forcing mode normal (fan.py:400). All safe to defer. Wire frames verified against hardware by author.

@bluetoothbot

Copy link
Copy Markdown
Collaborator

PR Review — feat: add Circulator Fan Pro (W1160) support

Merge-ready. No blocking findings — everything remaining is a non-blocking suggestion, unchanged since 34820d6.

What I verified against the codebase, not just the diff:

  • Advertisement routing is sound. b"\x11\xb3@" collides with nothing in SUPPORTED_TYPES, the \x00/\x01 suffix pair matches the convention every other 4-byte entry uses, and _find_model_from_service_data cannot mis-claim it (neither chr(0) nor b"\x00" is a registered key), so the suffix path at adv_parser.py:998 is reached exactly as the tests assert.
  • The parser decodes the sample vector exactly: 0xd9 → charging + battery 89, 0x98 → on / mode 1 / light off / no swing, 0x0b → speed 11. The len(mfr_data) < 10 guard covers the deepest read (mfr_data[9]), and out-of-range mode bytes fall through .get() to None.
  • The night-light bit layout is self-consistent both ways — command bit0/bit1 mirrors advertisement bit2/bit3, and the three parametrized captures (0x98/0x94/0x9c) pin it down.
  • MRO is correct. _send_command resolves to SwitchbotEncryptedDevice, _get_basic_info to SwitchbotFan (so the cmd argument binds), update_from_advertisement to SwitchbotSequenceDevice; the SwitchbotEncryptedDevice, SwitchbotFan base order matches the SwitchbotStripLight3 precedent.
  • The previously blocking oscillation issue stays fixed — all six frames carry the 0x29 control-source byte and all six are asserted byte-for-byte in test_fan.py, with both base methods overridden because the base reads module-level constants rather than ClassVars.

Non-blocking items:

  • get_basic_info fires two encrypted round-trips but uses only _data1[2], and that happens after every command and every advertisement change — purely to re-read an unchanging firmware byte. It also returns {} on truncated data, which update() reads as failure but verify_encryption_key() reads as success.
  • The night-light surface duplicates a concept NightLightState + set_night_light already covers on the Standing Fan. Note if you unify it: the Pro's on-low byte 0x03 collides with NightLightState.OFF = 3, so it needs a value map, not a passthrough.
  • set_percentage(0) turns the fan on at speed 1, and the hardcoded mode byte silently resets natural/sleep/hurricane back to normal.
  • The __init__ override is a pure pass-through — the base already falls back to _model.
  • process_circulator_fan_pro re-implements _parse_fan instead of parameterizing it, so shared-guard fixes will need applying twice.
  • Description drift: the body says presets are "direct / …" but the enum exposes "normal", and the new fan_modes property on the base SwitchbotFan (so it affects every fan class) isn't mentioned. Two comments also carry Chinese glosses (飓风, 开关机) that duplicate the adjacent English.

✅ Resolved since last review (1)

Previously-flagged issues verified fixed
  • switchbot/devices/fan.py:372 get_basic_info makes two encrypted round-trips to return only firmware, and returns {} on truncated data

🟢 Suggestions

1. Night-light API diverges from the fan family's existing NightLightState surface
switchbot/devices/fan.py:434-455

switchbot/const/fan.py already defines NightLightState (LEVEL_1=1, LEVEL_2=2, OFF=3), and SwitchbotStandingFan exposes it via set_night_light(state) / get_night_light_state() with the advertisement key nightLight. The Pro adds a second, differently-shaped API for the same concept in the same module:

  • write: turn_on_light(low: bool) + turn_off_light() instead of set_night_light(...)
  • read: is_night_light_on() (bool) + get_night_light_level() (0/1/2) instead of get_night_light_state()
  • adv keys: night_light_is_on / night_light_level instead of nightLight

Why it matters: downstream consumers (the Home Assistant switchbot integration in particular) need two code paths for the same two-level night light across two devices in the same family, and the read/write shapes are asymmetric — you read back 2 but write low=True.

Important caveat if you do unify — do not pass NightLightState values through to the wire. The Pro's encoding is a genuine bitfield (bit0 on/off, bit1 level), so on-low is 0x03, which collides with NightLightState.OFF = 3; and LEVEL_2 = 2 would send bit1-only, i.e. off. The legacy _parse_fan reads the same two advertisement bits as a 2-bit integer, so the two decodings really are different. A unified API therefore needs an explicit Pro-side value map, not a passthrough:

  • OFF0x00, LEVEL_10x01, LEVEL_20x03

That keeps the verified wire bytes exactly as they are today while giving consumers one method name and one enum across both fans. If you prefer to keep the current bool-based surface, that is defensible — but then the divergence is worth a line in the class docstring so the next reader does not assume set_night_light should have worked.

    async def turn_on_light(self, low: bool = False) -> bool:
    async def turn_off_light(self) -> bool:
    def is_night_light_on(self) -> bool | None:
    def get_night_light_level(self) -> int | None:

Checklist

  • Adv parser length guard covers all indexed bytes
  • Adv routing signature unique, no SUPPORTED_TYPES collision
  • MRO / encrypted _send_command resolution correct
  • All claimed features have tested command frames
  • Public API consistent with existing device family — suggestion #1
  • No redundant I/O or dead code
  • New public symbols exported (init/all)
  • Test coverage incl. edge cases (None/short data)
  • No hardcoded secrets / unsafe deserialization
  • PR description matches diff — suggestion #1
ℹ️ Triage summary

1 pre-existing finding(s) on unchanged code suppressed (freeze).


Automated review by Kōan (Claude) HEAD=a51e322 6 min 8s

@bluetoothbot bluetoothbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tip

No blocking issues found — ready to merge.

Comment thread switchbot/devices/fan.py
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

@bluetoothbot

Copy link
Copy Markdown
Collaborator

__init__ override on SwitchbotCirculatorFanPro is no longer needed

switchbot/devices/fan.py:364 re-adds the model-default constructor boilerplate that #476 (e75f8c7) removed across the encrypted devices. SwitchbotEncryptedDevice.__init__ already falls back to the subclass _model:

# switchbot/devices/device.py:1013
if model is None:
    model = self._model

Since the class already sets _model = SwitchbotModel.CIRCULATOR_FAN_PRO, the override is a pure pass-through — and BLEDevice is imported only for its annotation, so it goes too:

-from bleak.backends.device import BLEDevice
-
-    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)

Verified on a51e322 with the 13 lines deleted: 1341 passed. The inherited signature is identical apart from model defaulting to None and resolving via _model, and MRO puts SwitchbotEncryptedDevice.__init__ first, so nothing changes for callers.

For reference, air_purifier, art_frame, evaporative_humidifier, light_strip, relay_switch and smart_thermostat_radiator all define no __init__ post-#476; the only survivors are lock.py (validates the model is a lock) and keypad_vision.py (makes model required) — both do real work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants