logo

Toon for Domoticz via dzVents

Tue June 11th 2019

Updated Wed June 26th 2019

I was not happy with the python plugin for Toon toonapilib4domoticz. Could not get it to work properly, and was using unnecessary resources etc ..

I was also missing some of the functionality of Home Assistant but dropped back to Domoticz since this was going to take too much work to convert to .. and again, it needed a lot of resources to get everything setup (more than my rpi2 which is plenty plenty for Domoticz)

So I started out to see if I could get the Toon api integrated in Domoticz via. Et Voila, it is working! Currently in testing fase.
Implemented features:

  • thermostat features, like burner on/of, errors (with notification),
  • Toon scenes, getting and setting (Comfort/Home/Sleep/Away/Holiday)
  • Toon program, getting and setting (on/off/temp)
  • powerUsage, even without the gaps I had with the old Toon device in Domoticz!
  • gasUsage
  • Implemented using just 1 Toon API call, the status call, so light on calls and resources for local and Toon
  • Nicely implemented reusing a login via refreshtokens (no unnecessary login calls)

Not Implemented (I don’t have those devices):

  • setting setPoint, it’s working but is disabled, dzVents does not support silent updates of setpoint yet
  • smoke sensors via Toon
  • Philips Hue via Toon
  • other stuff I don’t know about
-- Toon for domoticz via dzVents
-- https://de-neef.net/notes/#toon-for-domoticz-via-dzvents
-- add domoticz forum url later
-- 2019-06-10 intial setup, authentication and get status 
-- 2019-06-11 added update for programState and scenes
-- 2019-06-15 added extra error handling and logging

-- api calls based on toonapilib & Toon api documentation
-- toon api credentials required via 'https://developer.toon.eu'
-- only using toons 1st agreement, fine if you have 1 Toon
-- only thermostat, p1 meter and gass supported
-- no hue, smokedetectors or others

-- create api account at https://developer.toon.eu
local        client_id="enter your api.toon.eu client_id"
local        client_secret="enter your api.toon.eu client_secret"

-- enter eneco credentials below (if not eneco, replace eneco below)
local        username="enter your eneco username"
local        password="enter your eneco password"

-- create devices in domoticz
-- create new hardware dummy device TOON and add virtual devices from there
-- at start the powerUsage will spike; select the spike click shift-leftmouse 
-- to delete the spike ;-)

local toonThermostat = "Toon thermostat"    -- type=Thermostat - SetPoint
local toonTemperature = "Toon temperature"  -- type=Temp - LacrosseTX3
local toonScenes = "Toon scenes"            -- type=Light/switch - selector switch
local toonProgram = "Toon program"          -- type=Light/switch - selector switch    
local toonModulation = "Toon modulation level" -- type=percentage
local toonBurnerInfo = "Toon burner info"   -- type=Light/switch - selector switch
local toonBoilerError = "Toon boiler state" -- type=text
local toonNextProgram = "Toon next program" -- type=text
local toonGasUsage = "Toon gas"             -- type=gas
local toonSmartMeter = "Toon smart meter"   -- type=p1 smart meter

local scenesText = { 'Comfort', 'Home', 'Sleep', 'Away', 'Holiday' }
local burnerInfo = { 'Burner off', 'Burner on', 'Hot Water', 'Preheat' } 

return {
    logging = { 
            level = domoticz.LOG_INFO -- comment when not in dev
        },
    on = {
        -- comment timer and create testButton for development
        -- timer manually specified to avoid 5min cutoffs (0) in charts 
        -- and add spreading, use *:1 to *:4 as starting point
        timer = { 'at *:4', 'at *:9', 'at *:14', 'at *:19', 'at *:24', 'at *:29', 'at *:34', 
            'at *:39', 'at *:44', 'at *:49', 'at *:54', 'at *:59' },
        devices = { toonProgram, toonScenes }, -- toonThermostat removed, no updateSetPoint().silent()
        httpResponses = { 'toon_getToken', 'toon_getAgreements', 'toon_getStatus', 'toon_setThermostat' },
    },
    data = {
        'access_token',
        'access_token_timestamp',
        'access_token_expires_in',
        'refresh_token',
        'agreementId',
        'displayCommonName',
        'action', 'actionData'
    },

    execute = function(dz, item) 

        -- execute command to get site
        function os.capture(cmd)          
            local s = ""
            local f = assert(io.popen(cmd, 'r'))
            s = assert(f:read('*a'))
            f:close()
            return s
        end

        function get_access_token()
            dz.log('in get_access_token', dz.LOG_DEBUG)
            -- get code, using curl because of the redirect
            local command = "curl --max-time 5 -i -X POST " ..
            "-H 'Host: api.toon.eu' " ..
            "-H 'Content-Type: application/x-www-form-urlencoded' " .. 
            "-d 'username=" .. username .. "&password=" .. password .. 
            "&tenant_id=eneco&response_type=code&client_id=" .. client_id ..
            "' 'https://api.toon.eu/authorize/legacy'"
            local tmp = os.capture(command)
            local code=string.match(tmp, '?code=(.*)&scope')

            -- get tokens
            dz.openURL({
                url = 'https://api.toon.eu/token',
                method = 'POST',
                callback = 'toon_getToken',
                headers = { ['Content-Type']='application/x-www-form-urlencoded' },
                postData = "client_id=" .. client_id .. 
                "&client_secret=" .. client_secret .. 
                "&code=" .. code .. "&grant_type=authorization_code"
             })
        end

        function refresh_token()
            dz.log('in refresh_token',dz.LOG_DEBUG)
            dz.openURL({
                url = 'https://api.toon.eu/token',
                method = 'POST',
                callback = 'toon_getToken',
                headers = { ['Content-Type']='application/x-www-form-urlencoded' },
                postData = "client_id=" .. client_id .. 
                "&client_secret=" .. client_secret .. 
                "&refresh_token=" .. dz.data.refresh_token .. "&grant_type=refresh_token"
             })
        end

        function get_agreements() 
            dz.log('in get_agreements', dz.LOG_DEBUG)
            dz.openURL({
                url='https://api.toon.eu/toon/v3/agreements',
                method='GET',
                callback='toon_getAgreements',
                headers = {
                    Authorization = 'Bearer ' .. dz.data.access_token,
                    ['Content-Type'] = 'application/json',
                    ['Cache-Control'] = 'no-cache'
                }
            })
        end

        function get_status()
            dz.log('in get_status', dz.LOG_DEBUG)
            dz.openURL({
                url='https://api.toon.eu/toon/v3/' .. dz.data.agreementId .. '/status',
                method='GET',
                callback='toon_getStatus',
                headers = {
                    Authorization = 'Bearer ' .. dz.data.access_token,
                    ['Content-Type'] = 'application/json',
                    ['Cache-Control'] = 'no-cache',
                    ['X-Agreement-ID'] = dz.data.agreementId,
                    ['X-Common-Name'] = dz.data.displayCommonName
                }
            })
        end

        function set_thermostat(pd)
            dz.log('in set_thermostat: ' .. dz.utils.toJSON(pd), dz.LOG_DEBUG)
            -- adjust json
            if (pd.programState) then
                pd['activeState'] = dz.utils.round(dz.devices(toonScenes).level/10)
                pd['currentSetpoint'] = 0 -- not relevant
            elseif (pd.activeState) then
                pd['programState'] = 2
                pd['currentSetpoint'] = 0 -- not relevant
                dz.devices(toonProgram).switchSelector(20).silent()
            elseif (pd.currentSetpoint) then
                pd['programState'] = 2
                pd['activeState'] = -1
            else
                -- something strange
                dz.log('invalid input for set_thermostat', dz.LOG_ERROR)
                return
            end
            dz.log('set_thermostat json: ' .. dz.utils.toJSON(pd), dz.LOG_DEBUG)
            -- removed, no working put in domoticz??
            -- dz.openURL({
            --     url = 'https://api.toon.eu/toon/v3/' .. dz.data.agreementId .. '/thermostat',
            --     method = 'POST',
            --     callback = 'toon_setThermostat',
            --     postData = pd,
            --     headers = {
            --         Authorization = 'Bearer ' .. dz.data.access_token,
            --         ['Content-Type'] = 'application/json',
            --         ['Cache-Control'] = 'no-cache',
            --         ['X-Agreement-ID'] = dz.data.agreementId,
            --         ['X-Common-Name'] = dz.data.displayCommonName
            --     }
            -- })
            local command = "curl --max-time 5 -i -s -X PUT " ..
            "-H 'Host: api.toon.eu' " ..
            "-H 'Authorization: Bearer " .. dz.data.access_token .. "' " ..
            "-H 'Content-Type: application/json' " .. 
            "-H 'Cache-Control:  no-cache' " ..
            "-H 'X-Agreement-ID: " .. dz.data.agreementId .. "' " ..
            "-H 'X-Common-Name: " .. dz.data.displayCommonName .. "' " ..
            "-d '" .. dz.utils.toJSON(pd) .. "' " ..
            "'https://api.toon.eu/toon/v3/" .. dz.data.agreementId .. "/thermostat'"
            dz.log('command: ' .. command, dz.LOG_DEBUG)
            local tmp = os.capture(command)
            dz.log(tmp, dz.LOG_DEBUG)
            local status=string.match(tmp, "HTTP/1.1 (.-)\r\n")
            if (status ~= "200 ") then -- don't forget the space
                dz.log('set_thermostat failed: [' .. status .. ']', dz.LOG_ERROR)
            else
                dz.log('set_thermostat success: [' .. status .. ']', dz.LOG_INFO)

            end
        end

        function next_action()
            dz.log('in next_action', dz.LOG_DEBUG)
            if (dz.data.action == "get_status") then
                get_status()
            elseif (dz.data.action == "set_thermostat") then
                set_thermostat(dz.data.actionData)
            else
                dz.log('next_action missing: ' .. dz.data.action, dz.LOG_ERROR)
            end
        end

        function get_thermostatInfo(ti)
            -- ti == item.json.thermostatInfo
            local t1, t2

            -- toon temp
            t1 = ti.currentDisplayTemp/100
            t2 = dz.devices(toonTemperature).temperature
            if ( t1 ~= t2) then
                dz.devices(toonTemperature).updateTemperature(t1)
            end

            -- toon setpoint
            t1 = ti.currentSetpoint/100
            -- t2 = dz.devices(toonThermostat).setPoint
            -- if (t1 ~= t2) then
                dz.log('updating toonThermostat setPoint', dz.LOG_DEBUG)
                dz.devices(toonThermostat).updateSetPoint(t1).silent()
            -- end

            -- toon programstate
            t1 = ti.programState*10
            t2 = dz.devices(toonProgram).level
            if (t1 ~= t2) then
                dz.devices(toonProgram).switchSelector(t1).silent()
            end

            -- toon scene
            t1 = ti.activeState*10
            t2 = dz.devices(toonScenes).level
            if (t1 ~= t2) then
                dz.devices(toonScenes).switchSelector(t1).silent()
            end

            -- toon modulation level
            t1 = ti.currentModulationLevel
            t2 = dz.devices(toonModulation).percentage
            if (t1 ~= t2) then
                dz.devices(toonModulation).updatePercentage(t1)
            end

            -- toon burnerinfo
            t1 = ti.burnerInfo*10
            t2 = dz.devices(toonBurnerInfo).level
            if (t1 ~= t2) then
                dz.devices(toonBurnerInfo).switchSelector(t1)
            end

            -- boiler errors
            t1 = "no errors"
            t2 = dz.devices(toonBoilerError).text
            if (ti.hasBoilerFault ~= 0) then
                t1 = 'error: ' .. tostring(item.json.thermostatInfo.errorFound)
            end
            if (t1 ~= t2) then
                dz.devices(toonBoilerError).updateText(t1)
                dz.notify('Boiler error', t1, dz.PRIORITY_HIGH, dz.SOUND_FALLING)
            end

            -- nextProgram
            t1 = "Toon autoprogram not active"
            t2 = dz.devices(toonNextProgram).text
            if (ti.nextProgram ~= -1) then
                local d = os.date("%H:%M", item.json.thermostatInfo.nextTime)
                t1 = 'at ' .. d .. ' set to ' .. scenesText[tonumber(item.json.thermostatInfo.nextState+1)]
                .. ' ' .. item.json.thermostatInfo.nextSetpoint/100 ..  '°C'
            end
            if (t1 ~= t2) then
                dz.devices(toonNextProgram).updateText(t1)
            end
        end

        -- control script
        local Time = require('Time')
        local now = Time()
        if (item.isTimer or item.isDevice) then
            -- set action from Device

            if (item.isTimer) then
                dz.log('Timer event was triggered by ' .. item.trigger, dz.LOG_INFO)
                dz.data.action="get_status"
                dz.data.actionData=nil
            elseif (item.isDevice) then
                dz.log('Device event was triggered by ' .. item.name, dz.LOG_INFO)
                if(item.name == toonProgram) then
                    dz.log('update from toonProgram device', dz.LOG_INFO)
                    dz.data.action = "set_thermostat"
                    dz.data.actionData = { programState = math.floor(item.level/10) }
                elseif (item.name == toonScenes) then
                    dz.log('update from toonScenes device', dz.LOG_INFO)
                    dz.data.action = "set_thermostat"
                    dz.data.actionData = { activeState = math.floor(item.level/10) }
                elseif (item.name == toonThermostat) then
                    dz.log('update from toonThermostat device', dz.LOG_INFO)
                    dz.data.action = "set_thermostat"
                    dz.data.actionData = { currentSetpoint = item.setPoint*100 }
                end
            else
                dz.log('update unknown', dz.LOG_ERROR)
                return
            end


            -- debug info
            if (dz.data.access_token ~= nil) then
                dz.log('access_token: ' .. dz.data.access_token, dz.LOG_DEBUG)
            end
            if (dz.data.access_token_timestamp ~= nil) then
                dz.log('access_token_timestamp: ' .. tostring(dz.data.access_token_timestamp.dDate), dz.LOG_DEBUG)
                dz.log('deltaT: ' .. tostring(now.compare(dz.data.access_token_timestamp).secs), dz.LOG_DEBUG)
                dz.log('access_token_expires_in: ' .. dz.data.access_token_expires_in, dz.LOG_DEBUG)
                dz.log('result: ' .. tostring(now.compare(dz.data.access_token_timestamp).secs > tonumber(dz.data.access_token_expires_in)), dz.LOG_DEBUG)
            end
            if (dz.data.refresh_token ~= nil) then
                dz.log('refresh_token: ' .. dz.data.refresh_token, dz.LOG_DEBUG)
            end

            -- poll toon data            
            -- compare timestamps
            if (dz.data.access_token == nil or 
                dz.data.access_token_timestamp == nil or 
                now.compare(dz.data.access_token_timestamp).secs > tonumber(dz.data.access_token_expires_in)) then
                dz.log('need to get new access_token', dz.LOG_INFO)
                if (dz.data.refresh_token ~= nil) then
                    dz.log('getting new access_token via refresh_token', dz.LOG_DEBUG)
                    refresh_token()
                else
                    dz.log('getting new accesst_token via get_access_token', dz.LOG_DEBUG)
                    get_access_token()
                end
            elseif (dz.data.agreementId == nil or dz.data.displayCommonName == nil) then
                dz.log('need to get agreements', dz.LOG_INFO)
                get_agreements()
            else
                dz.log('all available, next_action', dz.LOG_DEBUG)
                next_action()
            end

        elseif (item.isHTTPResponse) then

            -- debug info
            dz.log('HTTPResponse event was triggered by ' .. item.trigger, dz.LOG_INFO)

            -- if no proper response report and stop
            if (not item.ok or item.statusCode ~= 200) then
                dz.log('response NOK, bailing out; ' .. tostring(item.statusCode)
                    .. ": " .. item.statusText, dz.LOG_ERROR)
                dz.log('responseOK: ' .. tostring(item.ok), dz.LOG_DEBUG)
                dz.log('statusCode: ' .. item.statusCode, dz.LOG_DEBUG)
                dz.log('statusText: ' .. item.statusText, dz.LOG_DEBUG)
                if (item.isJSON) then
                    dz.log('json data:', dz.LOG_DEBUG)
                    dz.log(dz.utils.toJSON(item.json), dz.LOG_DEBUG)
                elseif (item.data) then
                    dz.log('non-json data:', dz.LOG_DEBUG)
                    dz.log(item.data, dz.LOG_DEBUG)
                end
                return
            end

            -- handle async http response               
            if (item.trigger == 'toon_setThermostat') then
                -- nothing here
            elseif (item.trigger == 'toon_getToken' 
                and item.json.access_token) then
                dz.data.access_token = item.json.access_token
                dz.data.access_token_expires_in = item.json.expires_in
                dz.data.access_token_timestamp = now
                dz.data.refresh_token = item.json.refresh_token
                dz.log('access_token: ' .. dz.data.access_token, dz.LOG_DEBUG)
                dz.log('refresh_token: ' .. dz.data.refresh_token, dz.LOG_DEBUG)
                -- skip getting agreementId if already available
                if (dz.data.agreementId == nil or dz.data.displayCommonName == nil) then
                    dz.log('need to get agreements', dz.LOG_INFO)
                    get_agreements()
                else
                    next_action()
                end

            elseif (item.trigger == "toon_getAgreements" and 
                item.json[1].agreementId) then
                dz.data.agreementId = item.json[1].agreementId
                dz.data.displayCommonName = item.json[1].displayCommonName
                dz.log('agreementId:' .. dz.data.agreementId, dz.LOG_DEBUG)
                dz.log('displayCommonName: ' .. dz.data.displayCommonName, dz.LOG_DEBUG)
                next_action()

            elseif (item.trigger == "toon_getStatus") then
                dz.log('callback get_status', dz.LOG_DEBUG)

                -- thermostatInfo
                if (item.json.thermostatInfo) then
                    get_thermostatInfo(item.json.thermostatInfo)
                else
                    dz.log('thermostatInfo missing in response', dz.LOG_ERROR)
                    dz.log(dz.utils.toJSON(item.json), dz.LOG_INFO)
                end


                -- gasUsage
                if (item.json.gasUsage) then
                    dz.devices(toonGasUsage).updateGas(item.json.gasUsage.dayUsage)
                else
                    dz.log('gasUsage missing in response', dz.LOG_ERROR)
                    dz.log(dz.utils.toJSON(item.json), dz.LOG_INFO)
                end

                -- powerUsage
                if (item.json.powerUsage) then
                    dz.log('p1: ' ..  item.json.powerUsage.meterReadingLow .. ', ' ..
                        item.json.powerUsage.meterReading .. ', ' .. 
                        item.json.powerUsage.meterReadingLowProdu .. ', ' ..
                        item.json.powerUsage.meterReadingProdu .. ', ' .. 
                        item.json.powerUsage.value .. ', ' ..
                        item.json.powerUsage.valueProduced, dz.LOG_INFO)
                    dz.devices(toonSmartMeter).updateP1(
                        item.json.powerUsage.meterReadingLow,
                        item.json.powerUsage.meterReading, item.json.powerUsage.meterReadingLowProdu,
                        item.json.powerUsage.meterReadingProdu, item.json.powerUsage.value,
                        item.json.powerUsage.valueProduced)
                else
                    dz.log('powerUsage missing in response', dz.LOG_ERROR)
                    dz.log(dz.utils.toJSON(item.json), dz.LOG_INFO)
                end


            else
                dz.log('unknwon trigger HTTPResponse: ' .. item.trigger, dz.LOG_ERROR)
            end

        end
    end
}