Adding my Windmill fan to Apple Home (without downloading the app!)
On a reccomendation from my younger sibling, I recently bought a Windmill fan for my dorm room. I think it looks pretty good, and I like how quiet it can get. Here in California, we rarely need a fan going full blast, so I like that I can have it spinning in "whisper" mode most of the time.
![]()
However, I refuse to understand why, in 2026, these accessories are not just Matter-compatible. I want to use my fan remotely; I refuse to download an app. So, I decided to reverse engineer the fan's API and get it running locally.
Setup
Sadly, even if we can avoid downloading an app, the user still needs an account on the Windmill dashboard. Once you have an account, though, everything else can be done using only the fan and a server running on your local wifi.
After creating an account, paste this script in the console to obtain a setup token.
(async () => {
if (location.hostname !== 'dashboard.windmillair.com') {
throw new Error('Run this on the signed-in Windmill dashboard.');
}
const app = document.getElementById('app');
const key = app && Object.keys(app).find(k => k.startsWith('__reactContainer$'));
const markedRoot = key && app[key];
const pending = [markedRoot?.stateNode?.current || markedRoot];
const visited = new Set();
let store;
while (pending.length && !store) {
const node = pending.pop();
if (!node || visited.has(node)) continue;
visited.add(node);
const props = node.memoizedProps;
const candidate = props?.store || props?.value?.store;
if (candidate?.dispatch && candidate?.getState) store = candidate;
pending.push(node.child, node.sibling);
}
if (!store) throw new Error('Could not find the dashboard session.');
await Promise.race([
store.dispatch({
type: 'WEB_GET_PROVISION_TOKEN',
ws: {request: {command: 129, query: [JSON.stringify({deviceName: 'Windmill Fan'})]}}
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('Token request timed out.')), 20000))
]);
const activation = store.getState().Devices?.bleDevice;
if (!activation) throw new Error('The server did not return a fan activation token.');
console.log('FAN_ACTIVATION', JSON.stringify(activation));
})().catch(error => console.error('Fan activation failed:', error.message));
The format should look something like this:
FAN_ACTIVATION {"id":123456,"productId":-1,"orgId":123456,"name":"New Device","token":"andLWnkXpwuq_GJjppeHJipwmabnzlUYWs","updatedAt":0,"firmwareUpdatedAt":0,"activatedAt":0,"disconnectTime":0,"connectTime":0,"metadataUpdatedAt":0,"lastReportedAt":0,"color":0,"orgName":"Home - ABC123","owner":"myemail@example.com","ownerId":123456,"ownerOrgId":123456,"excludeFromAutomations":false,"locationId":0,"automationsCount":0,"isLocked":false}
Next, if you press and hold the WiFi button on the fan, you should see a WiFi hotspot. There is a setup page at http://192.168.4.1/, and /board_info.json contains the model, firmware, and MAC of the device.
We can use these values to request the fan be activated with Windmill's servers:
GET /external/api/provision?token=YOUR_DEVICE_TOKEN&templateId=TMPL0fMCd5wCe&ver=0.1.7&fwType=TMPL0fMCd5wCe&type=MCU&boType=ESP32 HTTP/1.1
Host: dashboard.windmillair.com
Now that the fan is "activated" we can setup the actual local WiFi. Back on the setup hostpot, we can make a POST request to /config with the WiFi information encoded as a form body:
ssid=YOUR_2_4_GHZ_WIFI_NAME
pass=YOUR_WIFI_PASSWORD
blynk=YOUR_DEVICE_TOKEN
host=dashboard.windmillair.com
port_ssl=443
Controlling the Fan
Rather than reinvent the wheel, the excellent homebridge-windmill-air package already implements most of what we want here. The Windmill API exposes these endpoints:
GET https://dashboard.windmillair.com/external/api/isHardwareConnected?token=YOUR_DEVICE_TOKEN
GET https://dashboard.windmillair.com/external/api/getAll?token=YOUR_DEVICE_TOKEN
GET https://dashboard.windmillair.com/external/api/batch/update?token=YOUR_DEVICE_TOKEN&v2=2&v0=1
Windmill expoes a config with five values but we only care about two of them: v2 is the fan speed (1=whisper, 5=boost, etc.) and v0 is the fan power (0=off, 1=on). Calling /batch/update allows us to set these values. The request above turns the fan on and sets the fan speed to level 2 (Low).
The rest of the hard work is just writing an adapter to HomeKit. We can use the HAP-python library to write a simple script that allows us to add the device to Apple Home. Running this script on the same network as your fan (i.e. on some kind of small server / always-on device) will let you control it remotely.
To use this script:
- Install dependencies, etc. in a venv.
- Place a
fan.jsonnext to the script with your token as the file contents:{"token":"YOUR_32_CHARACTER_DEVICE_TOKEN"} - Check connectivity to the actual fan using
.venv/bin/python windmill_home.py --check - Find your computer's local IP (e.g.
192.168.1.123) and start the Homekit server as follows:.venv/bin/python windmill_home.py --host 192.168.1.123 - On first setup, the program prints a QR code to
.homekit/pairing.png. Open it and then pair the accessory in Apple Home.
Other than this annoying setup process, I am very much enjoying the fan, and can't reccomend it enough. Keep an eye out here for more articles about other things I hope to automate in my room.