Based on the original project by Sebastian Linz: https://github.com/sebastianlinz/FanControl
A web-application to control a fan via a Particle Photon. The web-application controls the fan's state and provides it to the Photon over HTTP.
| Layer | Technology |
|---|---|
| Runtime | Node.js 18+ |
| Web framework | Express 4 |
| Templating | Pug |
| Logging | Winston |
| Zwift integration | zwift-mobile-api (polling, patched via patch-package) |
| Particle integration | Particle Cloud SSE (eventsource v4) |
| Firmware | C++ (Particle Photon / Wiring) |
| Containerisation | Docker / Docker Compose |
| Testing | Jest + Supertest |
The Photon makes an HTTP GET request to /getFanLevel every 5 seconds and receives a simple line of text containing the current fan state and level:
FCS4FLV1PWR0095HR110SPD027.3
| Field | Example | Meaning |
|---|---|---|
FCS |
4 |
Fan Controller State (mode, see below) |
FLV |
1 |
Fan Level (0–3, actual speed sent to relay) |
PWR |
0095 |
Current power in watts (from Zwift) |
HR |
110 |
Current heartrate in bpm (from Zwift) |
SPD |
027.3 |
Current speed in km/h (from Zwift) |
| State | Mode | Description |
|---|---|---|
0 |
Off | Fan off |
1 |
Level 1 | Fan at fixed speed 1 |
2 |
Level 2 | Fan at fixed speed 2 |
3 |
Level 3 | Fan at fixed speed 3 |
4 |
Zwift Simulation | Fan level set automatically based on current riding speed |
5 |
Zwift Workout | Fan level set automatically based on power, gated by heartrate |
Fan level is determined by current speed (km/h), configurable via .env:
SPEED_LEVEL1=10 # below this → level 0 (off)
SPEED_LEVEL2=30 # below this → level 1
SPEED_LEVEL3=40 # below this → level 2
# above → level 3
Fan level is determined by power (watts), but only if heartrate is above the configured threshold. During recovery intervals when heartrate drops, the fan turns off automatically.
HEARTRATE=125 # fan stays off if heartrate is at or below this
POWER_LEVEL1=150 # below this → level 0 (off)
POWER_LEVEL2=195 # below this → level 1
POWER_LEVEL3=265 # below this → level 2
# above → level 3
In Zwift modes (states 4 and 5), if no successful Zwift API poll has been received within the last 10 seconds, /getFanLevel returns fan level 0 (off) rather than acting on outdated values. This ensures the fan turns off safely if the Zwift connection is lost.
- Node.js (v18+) or Docker
- A valid Zwift account
- Your Zwift player ID (the number from the
userXXXXXXfolder on your Zwift PC)
git clone git@gitlab.com:alex.tull/fancontrol.git
cd fancontrolCopy the example file and fill in your details:
cp .env.example .envEdit .env:
ZWIFT_USERNAME=your_zwift_email@example.com
ZWIFT_PASSWORD=your_zwift_password
ZWIFT_PLAYER_ID=your_player_id
SPEED_LEVEL1=10
SPEED_LEVEL2=30
SPEED_LEVEL3=40
HEARTRATE=125
POWER_LEVEL1=150
POWER_LEVEL2=195
POWER_LEVEL3=265
# Optional: shared secret to authenticate Photon requests (see Security below)
PHOTON_SECRET=
# Optional: Particle Cloud log forwarding (see Security below)
PARTICLE_DEVICE_ID=your-24-char-device-id
PARTICLE_ACCESS_TOKEN=your-api-user-token
PARTICLE_PRODUCT_ID=your-product-slug-or-id
# Optional: log level (debug | info | warn | error, default: info)
LOG_LEVEL=info
# Optional: write logs to a file in addition to the console
# LOG_FILE=logs/fancontrol.lognpm install
npm startNote on
zwift-mobile-apipatch: The upstreamzwift-mobile-apipackage (v0.3.19) uses outdated Zwift API endpoints. Apatch-packagepatch inpatches/is automatically applied by thepostinstallscript duringnpm install. It fixes the auth URL and switchesriderStatus()to use the protobuf endpoint that Zwift now requires. No manual action is needed.
docker compose up --build -dhttp://localhost:3033
Or use the host's IP address to access from a mobile phone on the same Wi-Fi network.
All state-changing POST requests (fan mode buttons) are protected by a double-submit cookie CSRF token. The token is embedded in each form and validated server-side using crypto.timingSafeEqual.
To prevent any device on the LAN from spoofing /getFanLevel responses, you can configure a shared secret:
- Generate a random secret, e.g.:
node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))" - Set it in
.env:PHOTON_SECRET=your-random-secret-here - Set the matching values in
photon-src/fancontroller.ino:#define HOST_SECRET "your-random-secret-here" #define HOST_SECRET_ENABLED 1
- Flash the updated firmware to the Photon.
When PHOTON_SECRET is set, the server rejects any /getFanLevel request that does not include the matching X-Photon-Secret header with a 403 Forbidden response. Leave PHOTON_SECRET empty to disable the check (useful during development).
Photon log events can be forwarded to the Node.js logger via the Particle Cloud SSE event stream. This is opt-in and requires no USB connection.
Setup:
- Find your device ID at console.particle.io → your product → Devices → your device → Device ID.
- Create an API user token for your product: console.particle.io → your product → API Users → New API User. Give it at least the
devices:getscope. - Find your product slug in the Particle console URL:
console.particle.io/{product-slug}/devices(e.g.my-fancontrol-43693). - Set all three in
.env:PARTICLE_DEVICE_ID=your-24-char-device-id PARTICLE_ACCESS_TOKEN=your-api-user-token PARTICLE_PRODUCT_ID=your-product-slug-or-id
- Restart the app — it will connect to the Particle SSE stream and log Photon events inline:
2026-03-21T10:00:00.000Z - info: [PHOTON] fan level changed 0 -> 2 2026-03-21T10:00:05.000Z - warn: [PHOTON] 3 consecutive HTTP failures, check connection to 192.168.178.115:3033
If PARTICLE_DEVICE_ID or PARTICLE_ACCESS_TOKEN is missing, log forwarding is silently disabled.
Note: The app uses the product event stream endpoint (
/v1/products/{product}/events/) which is the only SSE endpoint accessible to API user tokens. The per-device endpoint (/v1/devices/{id}/events/) and the global endpoint (/v1/events/) both return errors for API user tokens.PARTICLE_PRODUCT_IDis required when using an API user token.
Events published by the Photon:
| Event name | Level | Trigger |
|---|---|---|
fancontrol/log |
info | Fan level changes |
fancontrol/warn |
warn | 3+ consecutive HTTP failures |
fancontrol/error |
error | Malformed response body |
The current fan state (fanState and fanLevel) is persisted to fanstate.json on every change and restored on startup. This means the fan resumes its previous mode after a process restart or reboot. The file is written atomically (via a .tmp rename) to prevent corruption on power loss.
fanstate.json is excluded from git and Docker builds.
Log output goes to the console by default. Set LOG_LEVEL=debug in .env to see detailed per-request logs including the exact payload sent to the Photon.
To also write logs to a file, set LOG_FILE in .env:
LOG_FILE=logs/fancontrol.logLog files are rotated at 5 MB, keeping the last 3 files. The log/ directory is mounted as a Docker volume so logs survive container restarts.
The test suite uses Jest and Supertest. No .env file or real Zwift account is needed — all external dependencies are mocked.
npm test| Suite | What is covered |
|---|---|
test/csrf.test.js |
CSRF middleware: token generation, uniqueness, safe-method pass-through, mismatch/missing-token rejection |
test/state.test.js |
Fan state persistence: in-memory updates, atomic disk writes, load() resilience against missing/corrupt/invalid files |
test/zwiftAdapter.test.js |
ZwiftAdapter: constructor defaults, updateSpeed, staleness window, _poll() success/404/403/401 handling, startPolling/stopPolling lifecycle |
test/routes.test.js |
HTTP routes: all speed boundary cases (Zwift Simulation), all heartrate×power boundary cases (Zwift Workout), /getFanLevel payload format, Photon secret auth, all POST fan-state routes with valid CSRF, CSRF rejection |
- Start the app with
npm startordocker compose up -d. - Open
http://localhost:3033in a browser. - Select Zwift-Simulation or Zwift-Workout mode.
- In a separate terminal, test the
/getFanLevelendpoint:Ifcurl http://localhost:3033/getFanLevel # FCS4FLV0PWR0000HR000SPD000.0PHOTON_SECRETis set, include the secret header (otherwise the server returns 403):curl -H "X-Photon-Secret: your-random-secret-here" http://localhost:3033/getFanLevel # FCS4FLV0PWR0000HR000SPD000.0
- If all Zwift values are zero, the player is not currently riding (this is normal when not in a session).
Note: The Zwift API only returns live data while you are actively riding. A 404 response means the player is not currently online in Zwift — this is expected behaviour and is logged at debug level only. In Zwift modes (states 4 and 5), speed, heartrate and power are logged at info level on every poll so they are always visible in the console.
This section describes how to install the Particle firmware on the Photon. You will need a fan with multiple speed levels, a Photon, a Particle Relay Shield, and a DC adapter.
⚠️ Use these instructions at your own risk. Be careful working with mains voltage.
The picture below shows how the relay shield cabling could look:
The fan's power cables are connected to the DC adapter. Connect the DC adapter to feed the relay shield (observe polarity). The phase (brown) of the mains feed cable connects to the relay COMM ports. The neutral (blue) connects to the neutral of the fan motor. Each fan speed cable connects to a NO (normally open) port of a relay.
- Create a Particle app in the Web IDE (e.g. "FanController").
- Copy the code from
photon-src/fancontroller.inointo the Web IDE. - Check and update
RELAY2,RELAY3, andRELAY4to match your wiring. - Set
HOST_IPto the IP address of the host running this app, andHOST_PORTto3033. - If using the shared secret, set
HOST_SECRETto matchPHOTON_SECRETin your.envand setHOST_SECRET_ENABLEDto1. - Flash the code to the Photon.
If the Photon is connected to a PC via USB, you can use a serial monitor (e.g. PuTTY) to view log output. The firmware logs:
- Every HTTP request with loop counter, timestamp, status code and latency
- The raw response body received from the server
- Fan level changes (e.g.
fan level changed 0 -> 2) - A warning after 3 consecutive HTTP failures
Thanks to Just Vervaart and Ogadai for the Zwift API library.
