Conditional timers and manual override

Moderator: leecollings

Post Reply
Spottyq
Posts: 3
Joined: Saturday 11 February 2017 10:56
Target OS: Raspberry Pi / ODroid
Domoticz version: 3.5877
Location: ::1
Contact:

Conditional timers and manual override

Post by Spottyq »

Hello,

Here is script used to make timers that can change dynamically and be overridden manually (until the next timer occurence.)

Take this snippet for instance :

Code: Select all

{['device'] = 'Boiler Parents', ['schedule'] = {
  {['value'] = 'On',  ['condition'] = presence({'Invités'})},
  {['value'] = 'On',  ['timeInterval'] = {{ 4, 0},{ 7,15}}},
  {['value'] = 'On',  ['timeInterval'] = {{12, 0},{16, 0}}, ['wDays'] = {0,0,0,0,0,1,1}},
  {['value'] = 'On',  ['timeInterval'] = {{19, 0},{22, 0}}},
  {['value'] = 'Off'}
 }
}
It will turn the water heater on whenever there are guests (‘Invités’) home (1st line).
During a normal day, it will turn on the morning (2) and evening (4), plus an extra period the afternoon (3).
Otherwise, it should be off (5).

presence() is a function that takes an array of strings. It checks if any virtual device named “Presence <someone>” is activated (the prefix and suffix of the virtual switch is configurable manually.)

I think it is pretty much human readable, while still allowing some complex schedules. :-)


I use this script with On/Off switches, dimmers (percentage) and thermostat setpoints (for these, please see my examples of how to set them up; the idx must be included.)

For thermostat, I have made a workaround for ugly graphs. It will set a value every 15 minutes, to ensure the graph line is horizontal. Activate it by changing the value of ‘thermostatGraphWorkaround’ to ‘true’.

I recommend you name the script ’Timers’ (otherwise, change the variable scriptName) and setting the script to run every minute (the ‘time’ option in Domoticz.)

Code: Select all

commandArray = {}
--[[
    This script is intended to be run every minute ('time' setting in Domoticz).
    Device is the Domoticz name of the device you want to control. Ex: ['device'] = 'Relay 1'
    Schedule contains a table with the schedule
    
    First format must contain : value. May contain: time, timeInterval, wDays, condition (something not explicitely defined is considered to be always true)
    A time. DO NOT ADD A LEADING 0. Ex : ['time'] = {23, 9} DON'T WRITE : {23,09}
    A timeInterval. Ex: 1:00 to 13:30 : ['timeInterval'] = {{ 1,30},{13, 0}}
                        00:00 to 1:00 and 20:00 to 24:00 : {{20, 0},{ 1, 0}}
    A wDays : Ex: only the weekends : ['wDays'] = {0,0,0,0,0,1,1}
    Whether it is enabled or not (default is enabled). Ex : ['enabled'] = false
    A value. May be anything. Will be sent to domoticz via the commandArray
    A condition. True if this timer is supposed to be active. (I recommend writing a function.) Ex:['condition'] = checkPresence()

    For some domoticz devices, the 'normal' way to control them does not work. If it is the case (for ex. thermostat devices) use a workaround
        - UpdateDevice. Should specify the idx of the device. Ex : ['idx'] = '45', ['type'] = 'UpdateDevice'
    
    If you want the manual override to work, create a uservariale named 'Timer-<deviceName>-LastSetPoint'. Ex : 'Timer-HallLight-LastSetPoint of type String.
    If that variable does not exist, the timer will work but the manual override won't.
    
    Assumptions :   - the first day of the week is monday
                    - Device state in Domoticz == actual device state
                    - nil is used internally; should not be used by a device
                    
    The first timer to match all his criterias is applied, without going further. A criteria that is not set is considered to be true
    If you don't set a value for a timer, the script won't update the device. Use if you want manual control.
    
    Known bugs :    - manual override only works if there were at least 2 minutes between automatic changes.
                    - manual override does not work with devices who use percentages
]]--
function initializeTimer()
    timers = {
        {['device'] = 'Boiler Aymeric', ['enabled'] = false, ['schedule'] = {
            {['value'] = 'On',  ['timeInterval'] = {{ 5,30},{ 8,30}}, ['condition'] = presence({'Aymeric', 'Quentin'})},
            {['value'] = 'On',  ['timeInterval'] = {{17, 0},{22, 0}}, ['condition'] = presence({'Aymeric', 'Quentin'})},
            {['value'] = 'On',  ['condition'] = presence({'Invités'})},
            {['value'] = 'Off'}
            }
        },
        {['device'] = 'Boiler Parents', ['enabled'] = false, ['schedule'] = {
            {['value'] = 'On',  ['condition'] = presence({'Invités'})},
            {['value'] = 'On',  ['timeInterval'] = {{ 4, 0},{ 7,15}}},
            {['value'] = 'On',  ['timeInterval'] = {{12, 0},{16, 0}}, ['wDays'] = {0,0,0,0,0,1,1}},
            {['value'] = 'On',  ['timeInterval'] = {{19, 0},{22, 0}}},
            {['value'] = 'Off'}
            }
        },
        {['device'] = 'Presence Quentin', ['enabled'] = false, ['schedule'] = {
            {['value'] = 'On',  ['timeInterval'] = {{19, 0},{ 8, 0}}},
            {['value'] = 'Off'}
            }
        },
        {['device'] = 'Presence Aymeric', ['schedule'] = {
            {['value'] = 'On',  ['wDays'] = {0,0,0,0,0,1,1}},
            {['value'] = 'On',  ['timeInterval'] = {{17, 0},{23,59}}, ['wDays'] = {0,0,0,0,1,0,0}},
            {['value'] = 'Off'}
            }
        },
        {['device'] = 'Ventilation', ['enabled'] = false, ['type'] = 'Dimmer', ['schedule'] = {
            {['value'] = 90, ['condition'] = presence({'Quentin', 'Invités'})},
            {['value'] = 50, ['timeInterval'] = {{ 6,45},{ 7,45}}},
            {['value'] = 50, ['timeInterval'] = {{17, 0},{21, 0}}},
            {['value'] = 10}
            }
        },
        {['device'] = 'Consigne Salon', ['idx'] = '45', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 20, ['timeInterval'] = {{ 9, 0},{20, 0}}, ['condition'] = presence({'Invités'})},
            {['value'] = 20, ['condition'] = chauffageHeureDePointe()},
            {['value'] = 18}
            }
        },
        {['device'] = 'Consigne Cuisine', ['idx'] = '40', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 19, ['timeInterval'] = {{15,30},{20, 0}}},
            {['value'] = 17}
            }
        },
        {['device'] = 'Consigne Bureau', ['idx'] = '261', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 18, ['condition'] = chauffageHeureDePointe()},
            {['value'] = 15}
            }
        },
        {['device'] = 'Consigne Mezzanine', ['idx'] = '43', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 19, ['condition'] = chauffageHeureDePointe()},
            {['value'] = 18}
            }
        },
        {['device'] = 'Consigne Ch Parents', ['idx'] = '38', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 17, ['timeInterval'] = {{ 6, 0},{ 6,40}}},
            {['value'] = 17, ['timeInterval'] = {{19, 0},{22, 0}}},
            {['value'] = 15}
            }
        },
        {['device'] = 'Consigne Ch Quentin', ['idx'] = '46', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 19, ['timeInterval'] = {{ 7, 0},{19, 0}}, ['condition'] = presence({'Quentin'})},
            {['value'] = 17, ['timeInterval'] = {{19, 0},{ 7, 0}}, ['condition'] = presence({'Quentin'})},
            {['value'] = 10}
            }
        },
        {['device'] = 'Consigne Hall', ['idx'] = '44', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 16}
            }
        },
        {['device'] = 'Consigne SdB Parents', ['idx'] = '262', ['type'] = 'UpdateDevice', ['schedule'] = {
            {['value'] = 19, ['timeInterval'] = {{ 6, 0},{ 8, 0}}},
            {['value'] = 18, ['timeInterval'] = {{21, 0},{22, 0}}},
            {['value'] = 17}
            }
        }
    }
end
-------------------
-- User functions
-------------------
function chauffageHeureDePointe()
    return isInTimeInterval({{15, 0},{21, 0}});
end








-------------------
-- Variable declarations
-------------------
scriptName = 'Timers'   --Used for debug messages
presencePrefix = 'Presence '
presenceSuffix = ''
thermostatGraphWorkaround = true    -- Sets the temperature every 15 minutes, even if it has not changed.
                                    -- Used to have better graphs in Domoticz.
                                    -- Will work only if the value is continually set (use timeInterval instead of time)
                                    -- !! Needs some uservariables
FirstDayOfWeekIsMonday = true --Maybe buggy. Has not been tested when false


domoticzIP='192.168.1.30'
now = {tonumber(os.date("%H")), tonumber(os.date("%M"))}
-------------------
-- Helper functions
-------------------
function needToActivateWorkaround(deviceName)
    if not thermostatGraphWorkaround then
        return false
    end
    if deviceName == nil then
        return exitFailure('needToActivateWorkaround : received nil');
    end
    if timeDifferenceInMin(otherdevices_lastupdate[deviceName]) >= 45 then
        return true
    end
    return false
end

function timeDifferenceInMin(s)
   year = string.sub(s, 1, 4)
   month = string.sub(s, 6, 7)
   day = string.sub(s, 9, 10)
   hour = string.sub(s, 12, 13)
   minutes = string.sub(s, 15, 16)
   seconds = string.sub(s, 18, 19)
   t2 = os.time{year=year, month=month, day=day, hour=hour, min=minutes, sec=seconds}
   difference = os.difftime (os.time(), t2)
   return math.floor(difference/60)
end

function getLastSetPointUserVarFromDeviceName(string)
    return 'Timer-' .. string .. '-LastSetPoint'
end

function setValueByUpdateDevice(deviceName, idx, value)
    if (deviceName == nil or idx == nil or value == nil) then
        return exitFailure('function setValueByUpdateDevice received some nil value')
    end
    
    value = tostring(value)
    if tonumber(otherdevices[deviceName]) ~= tonumber(value) then
        if tonumber(value) ~= tonumber(uservariables[getLastSetPointUserVarFromDeviceName(deviceName)]) then --SetPoint has just changed
            commandArray['Variable:' .. getLastSetPointUserVarFromDeviceName(deviceName)] = value
            commandArray[#commandArray + 1] = {['UpdateDevice']=idx .. '|' .. value .. '|' .. value}
        elseif needToActivateWorkaround(deviceName) then --Manual override + graphWorkaround
            value = tostring(otherdevices[deviceName])
            commandArray[#commandArray + 1] = {['UpdateDevice']=idx .. '|' .. value .. '|' .. value}
        end
    elseif needToActivateWorkaround(deviceName) then --Graphworkaround
        commandArray[#commandArray + 1] = {['UpdateDevice']=idx .. '|' .. value .. '|' .. value}
    end
end

function checkDevice(device)
    if device.enabled ~= nil and not (device.enabled == true or device.enabled == 1 or device.enabled == 'true') then
        return true
    end
    local value = getCurrentValue(device.schedule)
    if value == nil then
        return true
    end
    if device.type == nil then
        setValue(device.device, value)
        return true
    elseif device.type == 'UpdateDevice' then
        if (device.idx == nil) then
            return exitFailure('function checkDevice : device ' .. device.device .. ' does not have an idx')
        end
        setValueByUpdateDevice(device.device, device.idx, value)
        return true
    elseif device.type == 'Dimmer' then
        setValueForDimmer(device.device, value);
        return true
    else
        return exitFailure('function checkDevice : device ' .. device.device .. ' has an unknow type : ' .. device.type)
    end
end

function setValue(device, value)
    if value ~= uservariables[getLastSetPointUserVarFromDeviceName(device)] then --SetPoint has just changed
        commandArray['Variable:' .. getLastSetPointUserVarFromDeviceName(device)] = value
        if otherdevices[device] ~= value then
            commandArray[device] = value
        end
    end
end

function setValueForDimmer(device, value)
    if tostring(value) ~= tostring(uservariables[getLastSetPointUserVarFromDeviceName(device)]) then --SetPoint has just changed
        commandArray['Variable:' .. getLastSetPointUserVarFromDeviceName(device)] = tostring(value)
        if otherdevices[device] ~= value then
            commandArray[device] = 'Set Level ' .. tostring(value)
        end
    end
end

-- May return nil (if there is no valid timer. In that case, the device won't be updated)
function getCurrentValue(schedule)
    local value
    for key, timer in pairs(schedule) do
        local val = getCurrentValueFromTimer(timer)
        if val ~= nil then
            value = val
            break
        end
    end
    return value
end

function getWeekDayOfToday()
    local weekDayOffSet = 1
    if (FirstDayOfWeekIsMonday) then
        weekDayOffSet = 0
    end
    wDay = tonumber(os.date('%w'))+weekDayOffSet
    if (wDay < 0 or wDay > 6) then
        return exitFailure('weekDayOffSet is wrongly set !')
    end
    return wDay
end

function isInTimeInterval(interval)
    if not (type(interval) == "table" and #interval == 2 and isAValidTime(interval[1]) and isAValidTime(interval[2])) then
        return exitFailure('function isInTimeInterval(interval) : interval is not valid or time is not valid')
    end
    if (compareTimes(interval[1], interval[2])) then
        return compareTimes(interval[2], now) or compareTimes(now, interval[1])
    else
        return compareTimes(now, interval[1]) and compareTimes(interval[2], now)
    end
end

function isItTime(time)
    if not isAValidTime(time) then
        return exitFailure('isItTime : received wrong time')
    end
    if time[2] == tonumber(os.date('%M')) and time[1] == tonumber(os.date('%H')) then
        return true
    else
        return false
    end
end

function isInWeekDay(wDays)
    if wDays == nil or #wDays ~= 7 then
        return exitFailure('wDays is declared incorrectly')
    end
    return wDays[getWeekDayOfToday()] == 1
end

function isAValidTime(time)
    if not (type(time) == 'table' and #time == 2) then
        return false
    end
    hour = time[1]
    min = time[2]
    if not (type(hour) == 'number' and type(min) == 'number') then
        return false
    elseif (hour < 0 or hour > 23 or min < 0 or min > 59) then
        return false
    end
    return true
end

-- Returns true if time1 is greater or equal to time2, false otherwise
function compareTimes(time1, time2)
    if (time1[1] > time2[1]) then
        return true
    elseif (time1[1] == time2[1]) then
        if (time1[2] >= time2[2]) then
            return true
        end
    end
    return false
end

-- Does not exit on its own… use return exitFailure(…)
function exitFailure(string)
    print(scriptName .. " failure ! Error : " .. string)
    return nil
end

function isempty(s)
  return s == nil or s == ''
end

-- Returns true if >= 1 person from the list is present; nil if there is an error
function presence(names)
    if (isempty(names)) then
        return exitFailure('function presence(...) : received nil')
    elseif (type(names) == 'array') then
        return exitFailure('function presence(...) : expecting a table')
    end
    for key, name in pairs(names) do
        if otherdevices[presencePrefix .. name .. presenceSuffix] == nil then
            return exitFailure('function presence(...) : switch does not exist for ' .. name)
        end
        if otherdevices[presencePrefix .. name .. presenceSuffix] == 'On' then
            return true
        end
    end
    return false
end

-- Returns the value or nil if the timer is not supposed to be active now
function getCurrentValueFromTimer(timer)
    valueSet = false
    for key, value in pairs(timer) do
        if key == 'value' then
            valueSet = true
        elseif key == 'time' then
            if not isItTime(timer.time) then
                return nil
            end
        elseif key == 'timeInterval' then
            if not isInTimeInterval(timer.timeInterval) then
                return nil
            end
        elseif key == 'wDays' then
            if not isInWeekDay(timer.wDays) then
                return nil
            end
        elseif key == 'condition' then
            if not timer.condition then
                return nil
            end
        else
            return exitFailure('function getValueFromTimer : error in a key name for a timer or too many conditions')
        end
    end
    if valueSet then
        return timer.value
    else
        return exitFailure('function getValueFromTimer : a timer has no value')
    end
end
--------------
-- Main
--------------
initializeTimer()

for key, device in pairs(timers) do
    if checkDevice(device) == nil then
        return exitFailure('Main : checkDevice returned nil')
    end
end

return commandArray
Let me know what you think of it !
zicht
Posts: 272
Joined: Sunday 11 May 2014 11:09
Target OS: Windows
Domoticz version: 2023.1+
Location: NL
Contact:

Re: Conditional timers and manual override

Post by zicht »

Looks nice, but why not using the timers in the GUI ?
I cannot think of any additional benefit in my home for this, maybe you have some great ideas that inspire me too ?
Rpi & Win x64. Using : cam's,RFXCom, LaCrosse, RFY, HuE, google, standard Lua, Tasker, Waze traveltime, NLAlert&grip2+,curtains, vacuum, audioreceiver, smart-heating&cooling + many more (= automate all repetitive simple tasks)
Spottyq
Posts: 3
Joined: Saturday 11 February 2017 10:56
Target OS: Raspberry Pi / ODroid
Domoticz version: 3.5877
Location: ::1
Contact:

Re: Conditional timers and manual override

Post by Spottyq »

The timers in the GUI are great as long as you don't need conditional timers. (Manual override works fine.)

In my hot water heater example, it is impossible to have the boiler always on when guests are home (when a virtual switch is turned on) using only GUI timers.

It also allow to edit all the timers in one place, rather than on each device. (Handy when you have 8 thermostats ! :) )
Post Reply

Who is online

Users browsing this forum: No registered users and 1 guest