configuration: {} triggers: - id: "1" configuration: cronExpression: 0/60 * * * * ? * type: timer.GenericCronTrigger - id: "3" configuration: itemName: fan_current_speed type: core.ItemStateChangeTrigger - id: "4" configuration: itemName: fan_command type: core.ItemStateChangeTrigger conditions: [] actions: - inputs: {} id: "2" configuration: type: application/javascript;version=ECMAScript-2021 script: >- /* Fan script version 1.21 (1.0) Creates (if needed) a mode item under group fan (naming according to script name) (1.0) Updates mode item (0=night,1=day,2=shower) (1.0) Updates speed/timer of fan (1.0) change settings according to your situation (speed can be set as number) (1.1) calculate/create fanRemainTime and fanMaxTime item (in order for the widget to show this) (1.1) easier debug messages/ improved null check for items with NULL value (instead of null) (1.2) added (optional) CO2 regulation, removed setting as low/medium/high. Only numbers (needed for checks/compares) (1.2) split day in morning, evening, afternoon and night (1.2) added (optional) VOC regulation (1.2) changed mode item (check constants below for values) (1.2) added (optional) home/away regulation (1.2) changed humidity to optional (1.21) fix for OH 4.0/4.01 change of Event functionality */ //##settings## var scriptName="fan";//scriptname is used in logging var group=scriptName;//default scriptname also used to find fan items //morning/afternoon/evening/night var nightSpeed=20;//night speed, integer value var morningSpeed=60;//morningspeed, integer value var afternoonSpeed=80;//afternoon speed, integer value var eveningSpeed=80;//evening speed, integer value var awaySpeed=morningSpeed;//away speed (optional, if you have a presence item) var minSpeed=20;//min speed for fan. Whatever the calculated value is, never drop below (to keep fan running) var maxSpeed=254;//max speed for fan. Whatever the calculated value is, never pass it var morningStart=7;// hour morning mode starts (>=) var afternoonStart=12;// hour afternoon mode starts (>=) var eveningStart=18;//hour evening mode starts (>=) var nightStart=23;//hour night mode starts (>=) //const seems to error when reusing script by OH var MODE_NIGHT=0;//10=active var MODE_MORNING=1;//11=active var MODE_AFTERNOON=2;//12=active var MODE_EVENING=3;//13=active //>=5 'special modes' var MODE_AWAY=5;//15=active var MODE_HUMIDITY=6;//16=active var MODE_CO2=7;//17=active var MODE_VOC=8;//18=active //<10 inactive mode (f.i. overridden by button press), >=10 active mode (set speed is active) //someone home item var presenceItem="Thuis";//name for optional presence item (usually a group item with one member for each person) var presenceInverted=false;//true if it's an "away" item. So OFF means someone is home. //humidity var humidityItem="";//name for item (""=default, from fan group) var humidSpeed=160;//speed for ventilation when humid var humidityTreshold=10;// %humidity rise to start var delayFanStart=5;//minutes to start fan AFTER humidity raises over treshold var delayFanEnd=10;//minutes to keep fan turning after humidity fan start (ignored when speed set to timer1/timer2/timer3) var delayFanNextStart=30;//minutes wait for next start //co2 var co2Item="";//name for item (""=default, CO2, ending with _CO2 or containing CO2) var co2LowPpm=900;//start point ppm. Above this value fan will increase speed var co2LowSpeed=eveningSpeed;//start point speed. This might not be the minimum for CO2 ventialtion. It will go down to day/nighspeed. var co2HighPpm=1200;//end point ppm. Above this value fan will no longer increase speed anymore var co2HighSpeed=160;//end point speed. This is also the maximum for CO2 ventilation //voc (using BME680 with bosch library, this seems to have strange peaks maybe due to recalibration. That's why the "safe" 20% extra to do some testing) var vocItem="";//name for item (""=default, VOC, ending with _VOC or containing VOC) var vocLowPpm=1;//start point ppm. Above this value fan will increase speed var vocLowSpeed=1;//start point speed (1=100% of day/night speed) var vocHighPpm=20;//end point ppm. Above this value fan will no longer increase speed anymore var vocHighSpeed=1.2;//end point speed (1.1=110% of day/night speed). This is also the maximum for VOC ventilation //auto reset var resetMinutes=30;//minutes, when passed manual command can reset to default day/night settings (-1 disable) var correctionMinutes=5;//minimum time for corrections on VOC/CO2 adjustments var activeTreshold=5;//speed deviation accepted for detection of active mode //smoothing (especially for CO2 correntions) var smoothing=true;//Smooting, true=on. var smoothingTime=15;//Number>0, datapoints to average. Since the rule runs once minute, 15=15 minutes. //init var debugMode=false;//log debug as info (easier than changing loglevel in karaf) console.info(scriptName+"script started"); var now = time.ZonedDateTime.now(); //get items var fanMode=getAddItem(group,"mode"); var fanSpeed=searchFirstItem(group,"current_speed"); var fanCommand=searchFirstItem(group,"command"); var humidity=null;//can be null if (humidityItem==""){ humidity=searchFirstItem(group,"humidity"); } else { humidity=searchFirstItem(null,"humidity"); } if (humidity!=null){ debug("humidity, using "+humidity.name); } var fanRemainTime=getAddItem(group,"timer_remaintime"); var fanMaxTime=getAddItem(group,"timer_maxtime"); var fanTime=searchFirstItem(group,"timer_time"); //CO2 optional, can be null! var co2=null; if (co2Item==""){ co2=searchBestItem(null,"CO2"); } else { co2=searchBestItem(null,co2Item); } if (co2!=null){ debug("co2, using "+co2.name); } //VOC optional, can be null! var voc=null; if (vocItem==""){ voc=searchBestItem(null,"VOC"); } else { voc=searchBestItem(null,vocItem); } if (voc!=null){ debug("voc, using "+voc.name); } //presense optional, can be null! var presence=null; if (presenceItem==""){ presence=searchBestItem(null,"home"); } else { presence=searchBestItem(null,presenceItem); } if (presence!=null){ debug("presence, using "+presence.name); } //condition is larger, substract 1 to include equal if (resetMinutes>=1){ resetMinutes--; } if (correctionMinutes>=1){ correctionMinutes--; } //create in mem vars (script object is cached in openHAB). Will be erased on script save/change if (isNaN(this.ruleChange)){ //did the rule change the value? this.ruleChange=false; this.ruleSpeed=""; //last update/correction of speed (-24 allows changes now) this.lastUpdate=now.minusHours(24); this.lastCorrection=now.minusHours(24); //last hour lowest humidity was calculated this.baselineHour=-1; this.baseline=-1; //countdown/delay for humidity fan start (minutes, depening on CRON script!) this.humidFanStart=-1000-delayFanNextStart; //no timer active this.timerRun=0; //reset smoothing this.smoothingData=[]; this.smoothingIndex=-1; //set first run (to init cron) this.ruleInit=true; this.manualInput=false; this.resetTrigger=false; //first run rule, erase button for openhab (itho addon won't respond) fanCommand.sendCommand('X'); } //triggered by cron, currentspeed item or command? var valueTrigger=isAvailable(this.event); if (valueTrigger && isAvailable(this.event.type)){ if (this.event.type=="TimerEvent" || this.event.type=="ExecutionEvent"){ //openHAB 4, event is always given). Ignore these two valueTrigger=false; } } var commandTrigger=false; if (valueTrigger && this.event.itemName==fanCommand.name){ commandTrigger=true; } /* what we want for remote/button presses 1) command change event from X -> Medium (or whatever) : Only set lastUpdate to temp disable rule steering. The widget needs the command for displaying correct button status 2) command will change fanSpeed so triggers fanspeed change event: Now we reset command from medium->X and active status of day/night (because speed is now medium value) 3) command change event from Medium -> X: all is done, just because step 2 set it back to X Sometimes the CRON event comes in between. Since we now are sure of manual changes the old bandwidth for steering (correctionTreshold) is obsolete. The lastUpdate is always right. */ debug("value="+valueTrigger+ " command="+commandTrigger+" event "+this.event+" commandStr="+fanCommand.state+" reset "+resetTrigger+" rule="+ruleChange+" ruleSpd="+ruleSpeed); //reset command if not X (displays buttons in widget right) only if speed changes or CRON job runs if ( (!valueTrigger || (valueTrigger && !commandTrigger)) && isAvailable(fanCommand.state) && fanCommand.state!='X'){ // debug("Reset command to X"); if (fanCommand.state=='reset'){ //special widget command resetTrigger=true; } //erase button for openhab (itho addon won't respond) fanCommand.sendCommand('X'); } //check for 'unexpected' change of fanspeed if (!commandTrigger && valueTrigger && ruleSpeed!="" && Number.isInteger(ruleSpeed)){ //allow small deviations, RRD4J can calc an intermediate average interfering with this logic if (Math.abs(fanSpeed.state-ruleSpeed)>activeTreshold && ruleChange){ //we got a last set value (previous run), but current speed is different. i.e. something else than rule changed it (too) debug("ruleChange reset "+fanSpeed.state+"!="+ruleSpeed); ruleChange=false; } else if (Math.abs(fanSpeed.state-ruleSpeed)<=activeTreshold && !ruleChange){ //small change, but ruleChange not set debug("ruleChange set "+fanSpeed.state+"!="+ruleSpeed); ruleChange=true; } } //why is script running? if (commandTrigger){ //triggered on command debug("trigger=external, command"); //Allow it by setting lastUpdate (this block corrections by rules for a period) lastUpdate=now; if (fanCommand.state!='reset' && fanCommand.state!='X'){ //low/medium/high/timer etc, the fan speed might not change or the CRON job may interfere/come early. Set manual input also now manualInput=true; } //no more processing (to show widget indicators right wait for speedchange event) }else if (valueTrigger && ruleChange){ //value changed by rule itself. Reset and ignore debug("trigger=rule"); ruleChange=false; }else if (valueTrigger && !resetTrigger){ //triggered by external factor, press on remote, dashboard, fan GUI. debug("trigger=external, fanspeed change"); if (isAvailable(fanMode.state) && fanMode.state>=10){ //make active mode inactive (except if it's the reset->X) fanMode.postUpdate(fanMode.state % 10); } //Allow it by setting lastUpdate (this block corrections by rules for a period) lastUpdate=now; //let cron know manual input is given manualInput=true; }else { //trigger is CRON job/time based if (resetTrigger){ debug("dag/night reset"); }else { debug("trigger=cron"); } //check remote if(fanTime.state>0 && now.isAfter(lastUpdate)){ //remote timer pressed, disable auto mode for 5 minutes debug("remote timer used"); lastUpdate=now.plusMinutes(5); } //reset ruleChange ruleChange=false; //new value is correction (true) or change (false) let correction=false; //are automatic changes allowd let changeAllowed=true; if (resetMinutes>0){ changeAllowed=now.isAfter(lastUpdate.plusMinutes(resetMinutes)); } let correctionAllowed=true; if (correctionMinutes>0){ correctionAllowed=now.isAfter(lastCorrection.plusMinutes(correctionMinutes)); } //current hour let hour=now.hour(); //new humidity baseline needed? if (humidity!=null && hour!=baselineHour){ //sensor present and update needed, recalc humidity baseline baselineHour=hour; baseline=humidity.history.averageBetween(now.minusMinutes(70),now.minusMinutes(10)); debug("baseline recalculated "+baseline); } let humididyFanOn=false; let humidityRunning=false; let humidityOverTreshold=false; if (humidity!=null){ //sensor present humidityOverTreshold=(humidity.state>baseline+humidityTreshold); } let timerMode=false; if (!Number.isInteger(humidSpeed) && humidSpeed.toString().toLowerCase().startsWith("timer")){ //speedsetting for humidity is timer (that automatic switched off) timerMode=true; } /* cycle humidFanStart positive number, humidity over treshold. Counting down to starting moment 0 : start fan (small) negative number. Counting down to ending moment (large) negative number. Counting down to (possible) restarting moment -1000-delayFanNextStart: Done. Waiting for restart (cycle repeats) */ if (resetTrigger || manualInput){ //force end of humidity cycle AND/OR reset it to wait time (so it won't reactivate instantly) humidFanStart=-1000; }else if (humidFanStart<=-1000 && humidFanStart>-1000-delayFanNextStart){ //wait for next allowed start humidFanStart--; if (timerMode){ //timer (by itho addon) determined end. Prevent auto set during wait changeAllowed=false; } if (humidFanStart<=-1000 && humidFanStart<=-1000-delayFanNextStart){ //end of wait period, reset baseline (humidity may have changed) baselineHour=-1; } } else if (humidFanStart<=-1000-delayFanNextStart && humidityOverTreshold){ //rise in humidity over treshold, set start delay timer humidFanStart=delayFanStart } else if (humidFanStart<=-1000){ //off (and keep off) }else if (humidFanStart==0){ //start delay finished, start fan humidFanStart--; humididyFanOn=true; // it will only be true this moment/interval/at start humidityRunning=true; // it will be true while running changeAllowed=true;//always start! debug("humidity fan start"); } else if (humidFanStart>-1000 && humidFanStart<=-delayFanEnd){ //end delay finished, end fan humidFanStart=-1000; if (timerMode){ //timer (by itho addon) determines end. Prevent auto set lastUpdate=now; changeAllowed=false; }else { //itho doesn't stop time, so allow/force it changeAllowed=true;//always end! } debug("humidity fan end"); } else { //decrease wait timer if (humidFanStart<0){ humidityRunning=true; } humidFanStart--; } //day or night? var day=true; var dayPeriod=MODE_EVENING; if (nightStart=nightStart && hourmorningStart && (hour>=nightStart || hour=morningStart && hour=afternoonStart && hour=eveningStart){ //evening dayPeriod=MODE_EVENING; } //init auto speedchange let mode=dayPeriod; let baseSpeed=null; let speed=null; let command=null; //init speedchange check let currentSpeed=fanSpeed.state; let checkSpeed=null; //default day/night setting switch(dayPeriod){ case MODE_MORNING: baseSpeed=morningSpeed; debug("morningmode "+baseSpeed); break; case MODE_AFTERNOON: baseSpeed=afternoonSpeed; debug("afternoonmode "+baseSpeed); break; case MODE_EVENING: baseSpeed=eveningSpeed; debug("eveningmode "+baseSpeed); break; case MODE_NIGHT: default: baseSpeed=nightSpeed; debug("nightmode "+baseSpeed); break; } //home? var home=true; if (day && presence!=null && ((!presenceInverted && presence.state=='OFF') || (presenceInverted && presence.state=='ON'))){ //day, presence item and not home debug("not home"); mode=MODE_AWAY; baseSpeed=awaySpeed; home=false; } //calc co2-speed var co2Speed=0;//default=off if (co2!=null){ //co2 sensor found let co2Value=parseInt(co2.state); if (co2Value>=co2HighPpm){ //max co2 correction co2Speed=co2HighSpeed; } else { //increasing correction, calc lineair between low & high co2Speed=parseInt(co2LowSpeed+(co2HighSpeed-co2LowSpeed)*(co2Value-co2LowPpm)/(co2HighPpm-co2LowPpm)); } if (co2Speed=vocHighPpm){ //max co2 correction vocSpeed=vocHighSpeed; } else { //increasing correction, calc lineair between low & high vocSpeed=parseInt(vocLowSpeed+(vocHighSpeed-vocLowSpeed)*(vocValue-vocLowPpm)/(vocHighPpm-vocLowPpm)); } if (vocSpeedco2Speed && humidSpeed>baseSpeed){ //humidity and speed is larger than currentspeed speed=humidSpeed; mode=MODE_HUMIDITY; } else if (humidityRunning){ //don't set (allow manual intervention), only check speed=humidSpeed; changeAllowed=false; mode=MODE_HUMIDITY; } else if (co2Speed> baseSpeed && co2Speed>=vocSpeed){ //co2 correction and larger than day/night/voc speed=co2Speed; mode=MODE_CO2; correction=true; } else if (vocSpeed> baseSpeed && vocSpeed>co2Speed){ //voc correction and larger than day/night/co2 speed=vocSpeed; mode=MODE_VOC; correction=true; } else { //use day/night default speed=baseSpeed; correction=true; } if (manualInput){ //forced manual input by blocking rule manualInput=false; lastUpdate=now; changeAllowed=false; } //smoothing if (smoothing && smoothingIndex<0){ //init it with day/night speed smoothingIndex=0; for (let r=0;r=maxSpeed){ //absolute fan max speed=maxSpeed; } debug("home="+home+" day="+day+" speed="+speed+" mode="+mode+" "+"change "+changeAllowed+" correct "+correctionAllowed + " sp="+ parseInt(currentSpeed)+" cormode=" + correction+ " mode="+mode+" humid="+humidFanStart); //only update items if values are calculated and changed (else rule will trigger itself creating loops) ruleSpeed=""; if (speed==null){ //no calculated speed, do nothing } else if (currentSpeed==speed && correction && ruleInit){ ruleChange=true; //lastUpdate=now; debug("Init rule speed "+speed+" (correction)"); } else if (changeAllowed && correctionAllowed && currentSpeed!=speed && correction){ //CO2/VOC correction allowed fanSpeed.sendCommand(speed); ruleChange=true; lastCorrection=now; console.info("Writing speed "+speed+" (correction)"); } else if (currentSpeed==speed && correction){ //CO2-VOC corrections NOT allowed but speed is ok. No logging } else if (correction){ //corrections NOT allowed debug("no correction allowed"); if (changeAllowed){ //changes are allowed, so it's on track. Force active mode currentSpeed=speed; } } else if (!changeAllowed && (currentSpeed!=speed || command!=null)){ //at the moment automatic changes are not allowed (and change is requested), log debug("no changes allowed"); } else if (!changeAllowed){ //at the moment automatic changes are not allowed (same speed, no logging) }else if (speed!=currentSpeed){ fanSpeed.sendCommand(speed); ruleChange=true; //lastUpdate=now; => only block after manual input? lastCorrection=now; console.info("Writing speed "+speed+" (change)"); }else if (ruleInit){ ruleChange=true; //lastUpdate=now; debug("Init rule speed "+speed+ " (change)"); } else if (command!=null){ //at the moment no commands will be generared, keep it for future changes? fanCommand.sendCommand(command); ruleChange=true; //lastUpdate=now; lastCorrection=now; console.info("writing command "+command); } //cron init done ruleInit=false; ruleSpeed=speed;//all change-events other than calculated speed are manual interventions/user input //mode active/applied? if (mode!=null && (changeAllowed|| ruleChange)){ //whatever rule did, it's planned so active mode mode+=10; } else if (mode!=null && checkSpeed!=null && Math.abs(checkSpeed-currentSpeed)<=activeTreshold){ //checkspeed supplied and same, active mode mode+=10; } else if (mode!=null && speed!=null && Math.abs(speed-currentSpeed)<=activeTreshold){ //default on speed mode+=10; } //mode changed, write if (mode!=null && mode!=parseInt(fanMode.state)){ fanMode.postUpdate(mode); } //reset nulls on new items if (!isAvailable(fanRemainTime.state)){ fanRemainTime.postUpdate(0); } if (!isAvailable(fanMaxTime.state)){ fanMaxTime.postUpdate(0); } //remaining (increased) fan time if(fanTime.state>0 && fanMaxTime.state==0){ //remote pressed and no maxtime yet (assume setting in minutes and one lost because of update frequency) fanMaxTime.postUpdate(Math.ceil(fanTime.state/1000/60)*60+60); fanRemainTime.postUpdate(Math.floor(fanTime.state/1000)); timerRun=1; }else if (timerRun==1 && fanTime.state==0 && fanMaxTime.state!=0){ //reset (remote) timer timerRun=0; fanMaxTime.postUpdate(0); fanRemainTime.postUpdate(0); } else if (timerRun==1){ //running remote, change time fanRemainTime.postUpdate(Math.floor(fanTime.state/1000)); } else if (humididyFanOn){ //auto run by humidity fanMaxTime.postUpdate(delayFanEnd*60); fanRemainTime.postUpdate(delayFanEnd*60); timerRun=2; } else if (!humidityRunning){ //end of (auto) run timerRun=0; debug("end timer"); }else if (timerRun==2){ //running auto, change time let r=delayFanEnd*60+humidFanStart*60;//(humidFanStart is negative minutes!) if (r<0){ //to be sure r=0; } fanRemainTime.postUpdate(r); } //reset if (timerRun==0 && (fanMaxTime.state!=0 || fanRemainTime.state!=0)){ fanMaxTime.postUpdate(0); fanRemainTime.postUpdate(0); } } //done console.info(scriptName+" done in "+ (-now.getMillisFromNow())+" millisec"); /* ##### functions */ //get an existiing item or create it by (optional) group and name) function getAddItem(group,name){ let item=searchFirstItem(group,name); if (item==null){ if (group==null || group==""){ //create item without group and add to model console.info('creating item '+name); item=items.addItem({ type: 'Number', name: name, label: name, tags: ['Point'] }); } else { //create item with group and add to model console.info('creating item '+group+'_'+name); item=items.addItem({ type: 'Number', name: group+'_'+name, label: name, groups: [group], tags: ['Point'] }); } } return item; } //search items with (optional) group and name (return null or item) function searchFirstItem(group, name){ let list=searchItem(group,name); if (list.length>0){ return list[0]; } else { return null; } } //search items with (optional) group and name (return null or item) function searchBestItem(group, name){ let list=searchAllItem(group,name); if (list.length==1){ return list[0]; } else if (list.length==0){ return null; }else { //exact match or shortest endswith _name name=name.toLowerCase(); let best=""; for (let n of list){ if (n.name.toLowerCase()==name){ //exact match, always wins return n; } else if (n.name.toLowerCase().endsWith("_"+name) && (best=="" || n.name.length