Automatic fan speed control based on heart rate zones for indoor cycling. Connects to your smart fan via WiFi and adjusts speed based on real-time heart rate data from a Bluetooth LE or ANT+ heart rate monitor.
- Philips CX3350/CX3550 - via encrypted CoAP protocol
- Dyson Pure Cool (TP04/DP04) - via local MQTT (credentials from WiFi sticker or cloud)
- Multi-brand fan support via pluggable controller architecture
- Automatic fan speed control based on HR zones
- BLE heart rate monitor support (Polar H10, Garmin HRM, Wahoo TICKR, etc.)
- ANT+ heart rate monitor support (requires a USB dongle and the
antextra) - Smart speed transitions:
- Hysteresis prevents flapping at zone boundaries
- Step-down delay allows recovery during rest intervals
- Immediate step-up when HR rises
- Persistent configuration - saves your max HR and HR device
- Cross-platform - works on macOS, Linux (including Raspberry Pi)
| HR Zone | % Max HR | Fan Speed |
|---|---|---|
| Zone 1-2 | < 70% | Low (~33%) |
| Zone 3 | 70-80% | Medium (~66%) |
| Zone 4-5 | > 80% | High (100%) |
Fan speeds are normalized to percentages and mapped to each fan's native range (1-3 for Philips, 1-10 for Dyson).
- Python 3.13+
- macOS or Linux (including Raspberry Pi)
- Supported smart fan on your local network
- Heart rate monitor (Bluetooth LE or ANT+ with USB dongle)
# Clone the repository
cd philips
# Install with uv (Philips fan + BLE support)
uv sync
# Install with Dyson support
uv sync --extra dyson
# Install with ANT+ support (requires USB dongle)
uv sync --extra ant
# Install all optional dependencies
uv sync --extra dyson --extra antuv run python -m fan_controllerOn first run, you'll be prompted to:
- Select your fan brand (Philips)
- Enter your fan's IP address
- Enter your maximum heart rate
- Select your HR monitor from discovered devices
Dyson requires device credentials for local MQTT control. Two methods are available:
Method 1: LOCAL (Recommended) - Extract credentials from device WiFi sticker
- Look for a sticker on your Dyson showing WiFi SSID (DYSON-xxx-xxx) and password
- No cloud authentication required
Method 2: CLOUD - Authenticate with Dyson account (requires 2FA)
- Use if you can't find the device sticker
- Requires Dyson account email, password, and email OTP code
# Step 1: Run setup wizard (one-time)
uv run python scripts/setup_dyson.py
# Step 2: Run the fan controller
uv run python -m fan_controllerConfiguration is saved to ~/.config/fan-controller/config.json.
uv run python -m fan_controller# Turn fan on/off
uv run python scripts/test_fan.py on
uv run python scripts/test_fan.py off
# Set specific speed
uv run python scripts/test_fan.py speed 2
# Cycle through all speeds
uv run python scripts/test_fan.py cycle
# Set special modes (Philips only)
uv run python scripts/test_fan.py sleep
uv run python scripts/test_fan.py naturaltest_fan.py uses your saved config for the fan IP. To target a different fan, set the FAN_IP environment variable, for example FAN_IP=192.168.1.100 uv run python scripts/test_fan.py on.
# BLE heart rate monitor
uv run python scripts/test_hr.py scan # Scan for devices
uv run python scripts/test_hr.py connect # Connect and monitor
uv run python scripts/test_hr.py test # Quick 30-second test
# ANT+ heart rate monitor (requires USB dongle)
uv run python scripts/test_hr_ant.py connect
uv run python scripts/test_hr_ant.py testConfiguration is stored in ~/.config/fan-controller/config.json:
{
"fan_brand": "philips",
"philips": {
"ip": "192.168.1.100"
},
"dyson": {
"serial": "",
"credentials": "",
"device_type": "",
"ip": null
},
"max_hr": 185,
"hr_source": "ble",
"hr_device_address": "12345678-1234-...",
"hr_ant_device_id": null,
"smoothing_window": 5,
"hysteresis_bpm": 5,
"step_down_delay": 5,
"zone_thresholds": [0.70, 0.80]
}| Setting | Description | Default |
|---|---|---|
fan_brand |
Fan type ("philips" or "dyson") | philips |
philips.ip |
IP address of Philips fan | - |
dyson.serial |
Dyson device serial (from setup) | - |
dyson.credentials |
Dyson device credentials (from setup) | - |
dyson.device_type |
Dyson device type code (e.g., "475") | - |
dyson.ip |
IP address for local connection (required) | null |
max_hr |
Your maximum heart rate | 180 |
hr_source |
HR monitor type ("ble" or "ant") | ble |
hr_device_address |
Saved BLE address of HR monitor | None |
hr_ant_device_id |
Saved ANT+ device ID | None |
smoothing_window |
Number of HR readings to average | 5 |
hysteresis_bpm |
BPM buffer at zone boundaries | 5 |
step_down_delay |
Readings before stepping down speed | 5 |
zone_thresholds |
HR % thresholds for zone 3 and 4 | [0.70, 0.80] |
This project works on Raspberry Pi. For best results:
- Use a virtual environment (uv handles this automatically)
- Install dependencies via pip, not apt, to avoid ARM-specific issues:
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone and install
git clone <repo>
cd philips
uv sync
# For Dyson support
uv sync --extra dyson
# For ANT+ support (install udev rules)
uv sync --extra ant
sudo python -m openant.udev_rules# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov=src/fan_controller --cov-report=term-missing# Format code
uv run ruff format src tests
# Lint code
uv run ruff check src tests --fix
# Type checking
uv run pyright src FanController (ABC)
├── connect()
├── disconnect()
├── set_speed_percent()
└── ...
│
┌────────────────┼────────────────┐
│ │ │
PhilipsFan DysonFan [Future]
(aioairctrl) (libdyson)
speeds: 1-3 speeds: 1-10
[HR Monitor] ---(BLE)---> [Python Service] ---(Protocol)---> [Smart Fan]
|
[Zone Mapper]
- Smoothing
- Hysteresis
- Step-down delay
- BLE Heart Rate: Connects to your HR monitor using the standard Heart Rate Service (0x180D)
- Zone Mapping: Converts HR readings to fan speed percentage based on configured zones
- Fan Control: Each fan implementation converts percentage to its native speed range
To add support for a new fan brand:
- Create a new file
src/fan_controller/<brand>_fan_control.py - Implement the
FanControllerABC fromfan_interface.py - Add config class in
config.py - Register in
fan_factory.py - Update
main.pyprompts
See dyson_fan_control.py as an example.
- Ensure the fan is on the same network as your computer
- Verify the IP address in config matches your fan
- Try power cycling the fan
- Try LOCAL method first: Use the WiFi sticker on your device (no cloud needed)
- For cloud auth: Verify your Dyson account email and password
- Check your email for the OTP code (may be in spam folder)
- Common country codes: AU (Australia), US, GB, DE, FR, CA, NZ
- If OTP requested too frequently, wait a few minutes
- Make sure your HR strap is on and active
- Check that Bluetooth is enabled
- Try running the scan multiple times
- Increase
hysteresis_bpm(default: 5) - Increase
smoothing_window(default: 5) - Increase
step_down_delay(default: 5)
- aioairctrl - Philips CoAP communication
- libdyson-neon - Dyson local MQTT communication (actively maintained)
- bleak - BLE communication
- openant - ANT+ communication
