Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
node_modules
!.gitkeep
screenlog.*
config.json
9 changes: 8 additions & 1 deletion ARTICLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ Create a JSON configuration file like so:
},
"zwiftConfig": {
"zwiftID": 1231421,
"pullingInterval": 2500
"pollingInterval": 2500,
"smoothCycles": 1,
"delayFanUp": 1,
"delayFanDown": 1
},
"thresholds": {
"power": {
Expand All @@ -117,8 +120,12 @@ Replace the
- `fanIP` by the IP or hostname of the ESP
- `dataProvider` leave `zwift` value. (Use `ant` value if you want to use the second ant+ stick)
- `observedData` choose from `power`, `speed`, `hr` depending on your preference and adjust the corresponding `thresholds`
- `undefFanLvl`: Fan level when data provider returns undefined
- `zwiftID`: Your Zwift ID
- `pollingInterval` Adjust the polling interval (in ms). Keep a high value to not experience too frequent speed changes.
- `smoothCycles` fan control will take the average of data from recent several polling cycles to determine the fan level. Set to 1 for instant fan level switching.
- `delayFanUp`: delay several cycles before turning the fan level up. Set this value above 1 if you don't want the fan to respond to short sprint efforts.
- `delayFanDown`: delay several cycles before turning the fan level down. Set this value above 1 if you don't want the fan to respond to brief recovery.

To run the program use the command

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ To configure command use a JSON config file.
},
"zwiftConfig": {
"zwiftID": 1231421,
"pollingInterval": 2500
"pollingInterval": 2500,
"smoothCycles": 1,
"delayFanUp": 1,
"delayFanDown": 1
},
"thresholds": {
"power": {
Expand All @@ -51,12 +54,16 @@ To configure command use a JSON config file.
- `fanIP`: IP or hostname of the fan
- `dataProvider`: `zwift`, `ant`, `mock` select data provider for the `observedData`
- `observedData`: `power`, `speed`, `hr` select data thresholds that trigger fan level change
- `undefFanLvl`: Fan level when data provider returns undefined

- `antConfig`: Specific configuration for `ant` data provider
- `wheelCircumference`: size of the wheel in meters - [Size chart](https://www.bikecalc.com/wheel_size_math#:~:text=Wheel%20diameter%20%3D%20(rim%20diameter),circumference%20%3D%20Wheel%20diameter%20*%20PI.)
- `zwiftConfig`: Specific configuration for `zwift` data provider
- `zwiftID`: Your zwift ID, more detail in the [Get Zwift ID](#get-zwift-id) Section
- `pollingInterval`: Pulling interval in milliseconds, (keep a value not too high)
- `smoothCycles`: fan control will take the average of data from recent several polling cycles to determine the fan level. Set to 1 for instant fan level switching.
- `delayFanUp`: delay several cycles before turning the fan level up. Set this value above 1 if you don't want the fan to respond to short sprint efforts.
- `delayFanDown`: delay several cycles before turning the fan level down. Set this value above 1 if you don't want the fan to respond to brief recovery.


# Installation
Expand Down
91 changes: 81 additions & 10 deletions cli/zwift-smart-fan-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ const SmartFan = require('../device-controller/smart-fan/smart-fan');
const yargs = require("yargs");
const {hideBin} = require("yargs/helpers");

var smoothVals = [];
var smoothValsIdx = 0;
var smoothCycles = 1;

var fanUpCycles = 0;
var fanDownCycles = 0;
var currFanLevel = 0;
var delayFanUp = 1;
var delayFanDown = 1;
var undefFanLvl = 0;

const options = yargs(hideBin(process.argv))
.option('config', {
type: 'string',
Expand All @@ -22,30 +33,84 @@ function getDataSource(config) {
case "zwift":
return new Zwift({zwiftID: config.zwiftConfig.zwiftID, pollingInterval: config.zwiftConfig.pollingInterval})
case "mock":
return new Mock({pollingInterval: 5000})
return new Mock({pollingInterval: config.zwiftConfig.pollingInterval})
default:
throw new Error('Unsupported data provider: ' + config.dataProvider);
}
}

function getLevel(value, thresholds) {
if (value >= thresholds.level3) {
return 3
var avgValue = 0;

if (value === undefined) {
// Fill smoothVals with 0
smoothVals.fill(0);
return undefFanLvl;
}
if (value >= thresholds.level2) {
return 2

smoothVals[smoothValsIdx] = value;
smoothValsIdx = (smoothValsIdx + 1) % smoothCycles;
let sum = smoothVals.reduce((accumulator, current) => accumulator + current, 0);
avgValue = Math.round(sum / smoothCycles);

// Turn on logging if needed
// console.log(smoothVals);
console.log(`Smoothed value: ${avgValue}`);

var newFanLevel = 0;

if (avgValue >= thresholds.level3) {
newFanLevel = 3;
}
if (value >= thresholds.level1) {
return 1
else if (avgValue >= thresholds.level2) {
newFanLevel = 2;
}
return 0;
else if (avgValue >= thresholds.level1) {
newFanLevel = 1;
}
console.log(`New fan level = ${newFanLevel}`);

if (newFanLevel == currFanLevel) {
fanUpCycles = 0;
fanDownCycles = 0;
}
else if (newFanLevel > currFanLevel) {
fanUpCycles++;
if (fanUpCycles >= delayFanUp) {
fanUpCycles = 0;
fanDownCycles = 0;
currFanLevel = newFanLevel;
}
else {
console.log("! Ignore fan up");
}
}
else if (newFanLevel < currFanLevel) {
fanDownCycles++;
if (fanDownCycles >= delayFanDown) {
fanDownCycles = 0;
fanUpCycles = 0;
currFanLevel = newFanLevel;
}
else {
console.log("! Ignore fan down");
}
}

return currFanLevel;
}

if (fs.existsSync(options.config)) {
const config = JSON.parse(fs.readFileSync(options.config).toString());
const dataProvider = getDataSource(config);
const smartFan = SmartFan({fanIP: config.fanIP});

smoothCycles = config.zwiftConfig.smoothCycles;
smoothVals.fill(0);
delayFanUp = config.zwiftConfig.delayFanUp;
delayFanDown = config.zwiftConfig.delayFanDown;
undefFanLvl = config.undefFanLvl;

switch (config.observedData) {
case "power":
dataProvider.power$.subscribe(value => smartFan.fanLevel(getLevel(value, config.thresholds.power)))
Expand All @@ -58,7 +123,13 @@ if (fs.existsSync(options.config)) {
break;
}

process.on('exit', () => {
smartFan.fanLevel(0)
['exit', 'SIGINT', 'uncaughtException'].forEach(function (sig) {
process.on(sig, function () {
let fanStat = smartFan.fanLevel(0);
fanStat.then(function(result) {
console.log("Exit reason:" + sig);
process.exit(0);
});
});
});
}
6 changes: 5 additions & 1 deletion config-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
"fanIP": "192.168.1.41",
"dataProvider": "zwift",
"observedData": "power",
"undefFanLvl": 0,
"antConfig": {
"wheelCircumference": 2.120
},
"zwiftConfig": {
"zwiftID": 1231421,
"pullingInterval": 2500
"pollingInterval": 2500,
"smoothCycles": 1,
"delayFanUp": 1,
"delayFanDown": 1
},
"thresholds": {
"power": {
Expand Down
14 changes: 8 additions & 6 deletions data-provider/mock/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ const {interval, map} = require("rxjs");
const configExample = require("../../config-example.json");

function getThresholds(index, thresholds) {
if (index % 3 === 0) {
return thresholds.level3
index = Math.floor(Math.random() * 3);
ratio = 1 + ((Math.random() - 0.5) * 0.4);
if (index % 3 === 2) {
return Math.floor(thresholds.level3 * ratio);
}
if (index % 2 === 0) {
return thresholds.level2
if (index % 3 === 1) {
return Math.floor(thresholds.level2 * ratio);
}
if (index % 1 === 0) {
return thresholds.level1
if (index % 3 === 0) {
return Math.floor(thresholds.level1 * ratio);
}
}
module.exports = function ({pollingInterval}) {
Expand Down
23 changes: 18 additions & 5 deletions data-provider/zwift/zwift.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,24 @@ module.exports = function ({zwiftID, pollingInterval}) {
}
});
const data = response.data.positions[0] || {};
subject.next({
power: data.powerOutput,
speed: data.speedInMillimetersPerHour / (1000 * 1000), // to kmh
hr: data.heartRateInBpm,
})
// Turn on logging if needed
// console.log(data)

// We need to check if the Zwift ID in data matches our own
if (data.id == zwiftID) {
subject.next({
power: data.powerOutput,
speed: data.speedInMillimetersPerHour / (1000 * 1000), // to kmh
hr: data.heartRateInBpm,
})
}
else {
subject.next({
power: undefined,
speed: undefined,
hr: undefined,
})
}
} catch (e) {
console.error(e)
}
Expand Down
79 changes: 43 additions & 36 deletions device-controller/smart-fan/smart-fan.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,57 @@
const axios = require('axios');
const util = require('util');


module.exports = function ({fanIP}) {
async function toggle(relayIndex) {
return (await axios.get(`http://${fanIP}`, {
params: {
'm': '1',
'o': `${relayIndex+1}`
},
})).data.match(/ON|OFF/g).map(onOff => onOff === "ON");
}
async function setFanStatus_tasmota(status) {
var strCmd = 'Backlog0 ';
var count = 0;
for (var i = 0; i < status.length; i++) {
if (status[i] == 'off') { // We turn off first and then turn on
var cmdParam = '';
if (count > 0) {
cmdParam = ';';
}
cmdParam = cmdParam.concat(util.format('Power%d off', i+1));
strCmd = strCmd.concat(cmdParam);
count++;
}
}
for (var i = 0; i < status.length; i++) {
if (status[i] == 'on') {
var cmdParam = '';
if (count > 0) {
cmdParam = ';';
}
cmdParam = cmdParam.concat(util.format('Power%d on', i+1));
strCmd = strCmd.concat(cmdParam);
count++;
}
}

async function getStatus() {
const axiosResponse = await axios.get(`http://${fanIP}`, {
const axiosResponse = await axios.get(`http://${fanIP}/cm`, {
params: {
'm': '1',
'cmnd': strCmd,
},
});
return axiosResponse.data.match(/ON|OFF/g).map(onOff => onOff === "ON");
}

async function on(index) {
const isOn = (await getStatus())[index];
if (!isOn) {
await toggle(index)
}
}

async function off(index) {
const isOn = (await getStatus())[index];
if (isOn) {
await toggle(index)
}
}

async function fanLevel(level) {
console.log("🌡 Fan Level: ", level)
switch (level) {
case 0:
return await Promise.all([off(0), off(1), off(2)]);
case 1:
return await Promise.all([on(0), off(1), off(2)]);
case 2:
return await Promise.all([off(0), on(1), off(2)]);
case 3:
return await Promise.all([off(0), off(1), on(2)]);
console.log(new Date().toISOString(), " 🌡 Fan Level: ", level)

try {
switch (level) {
case 0:
return await Promise.all([setFanStatus_tasmota(['off', 'off', 'off'])]);
case 1:
return await Promise.all([setFanStatus_tasmota(['on', 'off', 'off'])]);
case 2:
return await Promise.all([setFanStatus_tasmota(['off', 'on', 'off'])]);
case 3:
return await Promise.all([setFanStatus_tasmota(['off', 'off', 'on'])]);
}
} catch(e) {
console.log(e);
}
}

Expand Down