util-evo.lua /size: 36 Kb    last modification: 2021-10-28 13:50
1if not modules then modules = { } end modules ['util-evo'] = {
2    version   = 1.002,
3    comment   = "library for fetching data from an evohome device",
4    author    = "Hans Hagen, PRAGMA-ADE, Hasselt NL",
5    copyright = "PRAGMA ADE",
6    license   = "see context related readme files"
7}
8
9-- When I needed a new boiler for heating I decided to replace a partial
10-- (experimental) zwave few-zone solution by the honeywell evohome system that can
11-- drive opentherm. I admit that I was not that satified beforehand with the fact
12-- that one has to go via some outside portal to communicate with the box but lets
13-- hope that this will change (I will experiment with the additional usb interface
14-- later). Anyway, apart from integrating it into my home automation setup so that I
15-- can add control based on someone present in a zone, I wanted to be able to render
16-- statistics. So that's why we have a module in ConTeXt for doing that. It's also
17-- an example of Lua and abusing LuaTeX for something not related to typesetting.
18--
19-- As with other scripts, it assumes that mtxrun is used so that we have the usual
20-- Lua libraries present.
21--
22-- The code is not that complex but figuring out the right request takes bit of
23-- searching the web. There is an api specification at:
24--
25--   https://developer.honeywell.com/api-methods?field_smart_method_tags_tid=All
26--
27-- Details like the application id can be found in several places. There are snippets
28-- of (often partial or old) code on the web but still one needs to experiment and
29-- combine information. We assume unique zone names and ids across gateways; I only
30-- have one installed anyway.
31--
32-- The original application was to just get the right information for generating
33-- statistics but in the meantime I also use this code to add additional functionality
34-- to the system, for instance switching between rooms (office, living room, attic) and
35-- absence for one or more rooms.
36
37-- todo: %path% in filenames
38
39require("util-jsn")
40
41local next, type, setmetatable, rawset, rawget = next, type, setmetatable, rawset, rawget
42local json = utilities.json
43local formatters = string.formatters
44local floor, div = math.floor, math.div
45local resultof, ostime, osdate, ossleep = os.resultof, os.time, os.date, os.sleep
46local jsontolua, jsontostring = json.tolua, json.tostring
47local savetable, loadtable, sortedkeys, sortedhash = table.save, table.load, table.sortedkeys, table.sortedhash
48local setmetatableindex, setmetatablenewindex = table.setmetatableindex, table.setmetatablenewindex
49local replacer = utilities.templates.replacer
50local lower = string.lower -- no utf support yet (encoding needs checking in evohome)
51
52local applicationid = "b013aa26-9724-4dbd-8897-048b9aada249"
53----- applicationid = "91db1612-73fd-4500-91b2-e63b069b185c"
54
55local report = logs.reporter("evohome")
56local trace  = false
57
58trackers.register("evohome.trace",function(v) trace = v end) -- not yet used
59
60local defaultpresets = {
61    interval    = 30 * 60,
62    files       = {
63        everything  = "evohome-everything.lua",
64        history     = "evohome-history.lua",
65        latest      = "evohome-latest.lua",
66        schedules   = "evohome-schedules.lua",
67        actions     = "evohome-actions.lua",
68        template    = "evohome.lmx",
69    },
70    credentials = {
71      -- username    = "unset",
72      -- password    = "unset",
73      -- accesstoken = "unset",
74      -- userid      = "unset",
75    },
76}
77
78local validzonetypes = {
79    ZoneTemperatureControl = true,
80    RadiatorZone           = true,
81    ZoneValves             = true,
82}
83
84local function validfile(presets,filename)
85    if lfs.isfile(filename) then
86        -- we're okay
87        return filename
88    end
89    if file.pathpart(filename) ~= "" then
90        -- can be a file that has to be created
91        return filename
92    end
93    local presetsname = presets.filename
94    if not presetsname then
95        -- hope for the best
96        return filename
97    end
98    -- we now have the full path
99    return file.join(file.pathpart(presetsname),filename)
100end
101
102local function validpresets(presets)
103    if type(presets) ~= "table" then
104        report("invalid presets, no table")
105        return
106    end
107    local credentials = presets.credentials
108    if not credentials then
109        report("invalid presets, no credentials")
110        return
111    end
112    local gateways = presets.gateways
113    if not gateways then
114        report("invalid presets, no gateways")
115        return
116    end
117    local files = presets.files
118    if not files then
119        report("invalid presets, no files")
120        return
121    end
122    for k, v in next, files do
123        files[k] = validfile(presets,v) or v
124    end
125    local data = presets.data
126    if not data then
127        data = { }
128        presets.data = data
129    end
130    local g = data.gateways
131    if not g then
132        local g = { }
133        data.gateways = g
134        for i=1,#gateways do
135            local gi = gateways[i]
136            g[gi.macaddress] = gi
137        end
138    end
139    local zones = data.zones
140    if not zones then
141        zones = { }
142        data.zones = zones
143        setmetatablenewindex(zones,function(t,k,v)        rawset(t,lower(k),v) end)
144        setmetatableindex   (zones,function(t,k)   return rawget(t,lower(k))   end)
145    end
146    local states = data.states
147    if not states then
148        states = { }
149        data.states = states
150        setmetatablenewindex(states,function(t,k,v)        rawset(t,lower(k),v) end)
151        setmetatableindex   (states,function(t,k)   return rawget(t,lower(k))   end)
152    end
153    setmetatableindex(presets,defaultpresets)
154    setmetatableindex(credentials,defaultpresets.credentials)
155    setmetatableindex(files,defaultpresets.files)
156    return presets
157end
158
159local function loadedtable(filename)
160    if type(filename) == "string" then
161        for i=1,10 do
162            local t = loadtable(filename)
163            if t then
164                report("file %a loaded",filename)
165                return t
166            else
167                ossleep(1/4)
168            end
169        end
170    end
171    report("file %a not loaded",filename)
172    return { }
173end
174
175local function savedtable(filename,data,trace)
176    savetable(filename,data)
177    if trace then
178        report("file %a saved",filename)
179    end
180end
181
182local function loadpresets(filename)
183    local presets = loadtable(filename)
184    if presets then
185        presets.filename = filename
186        presets.filepath = file.expandname(file.pathpart(filename))
187     -- package.extraluapath(presets.filepath) -- better do that elsewhere and once
188    end
189    return presets
190end
191
192local function loadhistory(filename)
193    if type(filename) == "table" and validpresets(filename) then
194        filename = filename.files and filename.files.history
195    end
196    return loadedtable(filename)
197end
198
199local function loadeverything(filename)
200    if type(filename) == "table" and validpresets(filename) then
201        filename = filename.files and filename.files.everything
202    end
203    return loadedtable(filename)
204end
205
206local function loadlatest(filename)
207    if type(filename) == "table" and validpresets(filename) then
208        filename = filename.files and filename.files.latest
209    end
210    return loadedtable(filename)
211end
212
213local function result(t,fmt,a,b,c)
214    if t then
215        report(fmt,a or "done",b or "done",c or "done","done")
216        return t
217    else
218        report(fmt,a or "failed",b or "failed",c or "failed","failed")
219    end
220end
221
222local f = replacer (
223    [[curl ]] ..
224    [[--silent --insecure ]] ..
225    [[-X POST ]] ..
226    [[-H "Authorization: Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=" ]] ..
227    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
228    [[-d "Content-Type=application/x-www-form-urlencoded; charset=utf-8" ]] ..
229    [[-d "Host=rs.alarmnet.com/" ]] ..
230    [[-d "Cache-Control=no-store no-cache" ]] ..
231    [[-d "Pragma=no-cache" ]] ..
232    [[-d "grant_type=password" ]] ..
233    [[-d "scope=EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account" ]] ..
234    [[-d "Username=%username%" ]] ..
235    [[-d "Password=%password%" ]] ..
236    [[-d "Connection=Keep-Alive" ]] ..
237    [["https://tccna.honeywell.com/Auth/OAuth/Token"]]
238)
239
240local function getaccesstoken(presets)
241    if validpresets(presets) then
242        local c = presets.credentials
243        local s = c and f {
244            username      = c.username,
245            password      = c.password,
246            applicationid = applicationid,
247        }
248        local r = s and resultof(s)
249        local t = r and jsontolua(r)
250        return result(t,"getting access token %a")
251    end
252    return result(false,"getting access token %a")
253end
254
255local f = replacer (
256    [[curl ]] ..
257    [[--silent --insecure ]] ..
258    [[-H "Authorization: bearer %accesstoken%" ]] ..
259    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
260    [[-H "applicationId: %applicationid%" ]] ..
261    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/userAccount"]]
262)
263
264local function getuserinfo(presets)
265    if validpresets(presets) then
266        local c = presets.credentials
267        local s = c and f {
268            accesstoken   = c.accesstoken,
269            applicationid = c.applicationid,
270        }
271        local r = s and resultof(s)
272        local t = r and jsontolua(r)
273        return result(t,"getting user info for %a")
274    end
275    return result(false,"getting user info for %a")
276end
277
278local f = replacer (
279    [[curl ]] ..
280    [[--silent --insecure ]] ..
281    [[-H "Authorization: bearer %accesstoken%" ]] ..
282    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
283    [[-H "applicationId: %applicationid%" ]] ..
284    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/location/installationInfo?userId=%userid%&includeTemperatureControlSystems=True"]]
285)
286
287local function getlocationinfo(presets)
288    if validpresets(presets) then
289        local c = presets.credentials
290        local s = c and f {
291            accesstoken   = c.accesstoken,
292            applicationid = applicationid,
293            userid        = c.userid,
294        }
295        local r = s and resultof(s)
296        local t = r and jsontolua(r)
297        return result(t,"getting location info for %a")
298    end
299    return result(false,"getting location info for %a")
300end
301
302local f = replacer (
303    [[curl ]] ..
304    [[--silent --insecure ]] ..
305    [[-H "Authorization: bearer %accesstoken%" ]] ..
306    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
307    [[-H "applicationId: %applicationid%" ]] ..
308    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/temperatureZone/%zoneid%/schedule"]]
309)
310
311local function getschedule(presets,zonename)
312    if validpresets(presets) then
313        local zoneid = presets.data.zones[zonename].zoneId
314        if zoneid then
315            local c = presets.credentials
316            local s = c and f {
317                accesstoken   = c.accesstoken,
318                applicationid = applicationid,
319                zoneid        = zoneid,
320            }
321            local r = s and resultof(s)
322            local t = r and jsontolua(r)
323            return result(t,"getting schedule for zone %a, %s",zonename or "?")
324        end
325    end
326    return result(false,"getting schedule for zone %a, %s",zonename or "?")
327end
328
329local f = replacer (
330    [[curl ]] ..
331    [[--silent --insecure ]] ..
332    [[-H "Authorization: bearer %accesstoken%" ]] ..
333    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
334    [[-H "applicationId: %applicationid%" ]] ..
335    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/location/%locationid%/status?includeTemperatureControlSystems=True" ]]
336)
337
338local function getstatus(presets,locationid,locationname)
339    if locationid and validpresets(presets) then
340        local c = presets.credentials
341        local s = c and f {
342            accesstoken   = c.accesstoken,
343            applicationid = applicationid,
344            locationid    = locationid,
345        }
346        local r = s and resultof(s)
347        local t = r and jsontolua(r)
348        return result(t and t.gateways and t,"getting status for location %a, %s",locationname or "?")
349    end
350    return result(false,"getting status for location %a, %s",locationname or "?")
351end
352
353local function validated(presets)
354    if validpresets(presets) then
355        local data = getlocationinfo(presets)
356        if data and type(data) == "table" and data[1] and data[1].locationInfo then
357            return true
358        else
359            local data = getaccesstoken(presets)
360            if data then
361                presets.credentials.accesstoken = data.access_token
362                local data = getuserinfo(presets)
363                if data then
364                    presets.credentials.userid = data.userId
365                    return true
366                end
367            end
368        end
369    end
370end
371
372local function findzone(presets,name)
373    if not presets then
374        return
375    end
376    local data = presets.data
377    if not data then
378        return
379    end
380    local usedzones = data.zones
381    return usedzones and usedzones[name]
382end
383
384local function getzonenames(presets)
385    if not presets then
386        return { }
387    end
388    local data = presets.data
389    if not data then
390        return { }
391    end
392    local t = sortedkeys(data.zones or { })
393    for i=1,#t do
394        t[i] = lower(t[i])
395    end
396    return t
397end
398
399local function gettargets(zone) -- maybe also for a day
400    local schedule = zone.schedule
401    local min      = false
402    local max      = false
403    if schedule then
404        local schedules = schedule.dailySchedules
405        if schedules then
406            for i=1,#schedules do
407                local switchpoints = schedules[i].switchpoints
408                for i=1,#switchpoints do
409                    local m = switchpoints[i].temperature
410                    if not min or m < min then
411                        min = m
412                    end
413                    if not max or m > max then
414                        max = m
415                    end
416                end
417            end
418        else
419            report("zone %a has no schedule",name)
420        end
421    end
422    return min, max
423end
424
425local function updatezone(presets,name,zone)
426    if not zone then
427        zone = findzone(presets,name)
428    end
429    if zone then
430        local oldtarget = presets.data.states[name]
431        local min = zone.heatSetpointCapabilities.minHeatSetpoint or  5
432        local max = zone.heatSetpointCapabilities.maxHeatSetpoint or 12
433        local mintarget, maxtarget = gettargets(zone)
434        -- todo: maybe get these from presets
435        if mintarget == false then
436            if min < 5 then
437                mintarget = 5
438             -- report("zone %a, min target limited to %a",name,mintarget)
439            else
440                mintarget = min
441            end
442        end
443        if maxtarget == false then
444            if max > 18.5 then
445                maxtarget = 18.5
446             -- report("zone %a, max target limited to %a",name,maxtarget)
447            else
448                maxtarget = max
449            end
450        end
451        local current = zone.temperatureStatus.temperature or 0
452        local target  = zone.heatSetpointStatus.targetTemperature
453        local mode    = zone.heatSetpointStatus.setpointMode
454        local state   = (mode == "FollowSchedule"                            and "schedule" ) or
455                        (mode == "PermanentOverride" and target <= mintarget and "permanent") or
456                        (mode == "TemporaryOverride" and target <= mintarget and "off"      ) or
457                        (mode == "TemporaryOverride" and target >= maxtarget and "on"       ) or
458                        (                                                        "unknown"  )
459        local t = {
460            name      = zone.name,
461            id        = zone.zoneId,
462            schedule  = zone.schedule,
463            mode      = mode,
464            current   = current,
465            target    = target,
466            min       = min,
467            max       = max,
468            state     = state,
469            lowest    = mintarget,
470            highest   = maxtarget,
471        }
472     -- report("zone %a, current %a, target %a",name,current,target)
473        presets.data.states[name] = t
474        return t
475    end
476end
477
478
479local function geteverything(presets,noschedules)
480    if validated(presets) then
481        local data = getlocationinfo(presets)
482        if data then
483            local usedgateways = presets.data.gateways
484            local usedzones    = presets.data.zones
485            for i=1,#data do
486                local gateways     = data[i].gateways
487                local locationinfo = data[i].locationInfo
488                local locationid   = locationinfo and locationinfo.locationId
489                if gateways and locationid then
490                    local status = getstatus(presets,locationid,locationinfo.name)
491                    if status then
492                        for i=1,#gateways do
493                            local gatewaystatus  = status.gateways[i]
494                            local gatewayinfo    = gateways[i]
495                            local gatewaysystems = gatewayinfo.temperatureControlSystems
496                            local info           = gatewayinfo.gatewayInfo
497                            local statussystems  = gatewaystatus.temperatureControlSystems
498                            if gatewaysystems and statussystems and info then
499                                local mac = info.mac
500                                if usedgateways[mac] then
501                                    report("%s gateway with mac address %a","using",mac)
502                                    for j=1,#gatewaysystems do
503                                        local gatewayzones = gatewaysystems[j].zones
504                                        local zonestatus   = statussystems[j].zones
505                                        if gatewayzones and zonestatus then
506                                            for k=1,#gatewayzones do
507                                                local zonestatus  = zonestatus[k]
508                                                local gatewayzone = gatewayzones[k]
509                                                if zonestatus and gatewayzone then
510                                                    local zonename = zonestatus.name
511                                                    local zoneid   = zonestatus.zoneId
512                                                    if validzonetypes[gatewayzone.zoneType] and zonename == gatewayzone.name then
513                                                        gatewayzone.heatSetpointStatus = zonestatus.heatSetpointStatus
514                                                        gatewayzone.temperatureStatus  = zonestatus.temperatureStatus
515                                                        local zonestatus = usedzones[zonename] -- findzone(states,zonename)
516                                                        local schedule   = zonestatus and zonestatus.schedule
517                                                        usedzones[zonename] = gatewayzone
518                                                        if schedule and noschedules then
519                                                            gatewayzone.schedule = schedule
520                                                        else
521                                                            gatewayzone.schedule = getschedule(presets,zonename)
522                                                        end
523                                                        updatezone(presets,zonename,gatewayzone)
524                                                    end
525                                                end
526                                            end
527                                        end
528                                    end
529                                else
530                                    report("%s gateway with mac address %a","skipping",mac)
531                                end
532                            end
533                        end
534                    end
535                end
536            end
537            savedtable(presets.files.everything,data)
538            return result(data,"getting everything, %s")
539        end
540    end
541    return result(false,"getting everything, %s")
542end
543
544local function gettemperatures(presets)
545    if validated(presets) then
546        local data = loadeverything(presets)
547        if not data or not next(data) then
548            data = geteverything(presets)
549        end
550        if data then
551            local updated = false
552            for i=1,#data do
553                local gateways     = data[i].gateways
554                local locationinfo = data[i].locationInfo
555                if locationinfo then
556                    local locationid = locationinfo.locationId
557                    if gateways then
558                        local status = getstatus(presets,locationid,locationinfo.name)
559                        if status then
560                            for i=1,#gateways do
561                                local g = status.gateways[i]
562                                local gateway = gateways[i]
563                                local systems = gateway.temperatureControlSystems
564                                if systems then
565                                    local s = g.temperatureControlSystems
566                                    for i=1,#systems do
567                                        local zones = systems[i].zones
568                                        if zones then
569                                            local z = s[i].zones
570                                            for i=1,#zones do
571                                                local zone = zones[i]
572                                                if validzonetypes[zone.zoneType] then
573                                                    local z = z[i]
574                                                    if z.name == zone.name then
575                                                        zone.temperatureStatus = z.temperatureStatus
576                                                        updated = true
577                                                    end
578                                                end
579                                            end
580                                        end
581                                    end
582                                end
583                            end
584                        end
585                    else
586                        report("no gateways")
587                    end
588                else
589                    report("no location info")
590                end
591            end
592            if updated then
593                data.time = ostime()
594                savedtable(presets.files.latest,data)
595            end
596            return result(data,"getting temperatures, %s")
597        end
598    end
599    return result(false,"getting temperatures, %s")
600end
601
602local function setmoment(target,time,data)
603    if not time then
604        time = ostime()
605    end
606    local t = osdate("*t",time )
607    local c_year, c_month, c_day, c_hour, c_minute = t.year, t.month, t.day, t.hour, t.min
608    --
609    local years   = target.years    if not years   then years   = { } target.years    = years   end
610    local d_year  = years[c_year]   if not d_year  then d_year  = { } years[c_year]   = d_year  end
611    local months  = d_year.months   if not months  then months  = { } d_year.months   = months  end
612    local d_month = months[c_month] if not d_month then d_month = { } months[c_month] = d_month end
613    local days    = d_month.days    if not days    then days    = { } d_month.days    = days    end
614    local d_day   = days[c_day]     if not d_day   then d_day   = { } days[c_day]     = d_day   end
615    local hours   = d_day.hours     if not hours   then hours   = { } d_day.hours     = hours   end
616    local d_hour  = hours[c_hour]   if not d_hour  then d_hour  = { } hours[c_hour]   = d_hour  end
617    --
618    c_minute = div(c_minute,15) + 1
619    --
620    local d_last = d_hour[c_minute]
621    if d_last then
622        for k, v in next, data do
623            local d = d_last[k]
624            if d then
625                data[k] = (d + v) / 2
626            end
627        end
628    end
629    d_hour[c_minute] = data
630    --
631    target.lasttime = {
632        year   = c_year,
633        month  = c_month,
634        day    = c_day,
635        hour   = c_hour,
636        minute = c_minute,
637    }
638end
639
640local function loadtemperatures(presets)
641    if validpresets(presets) then
642        local status = loadlatest(presets)
643        if not status or not next(status) then
644            status = loadeverything(presets)
645        end
646        if status then
647            local usedgateways = presets.data.gateways
648            for i=1,#status do
649                local gateways = status[i].gateways
650                if gateways then
651                    for i=1,#gateways do
652                        local gatewayinfo = gateways[i]
653                        local systems     = gatewayinfo.temperatureControlSystems
654                        local info        = gatewayinfo.gatewayInfo
655                        if systems and info and usedgateways[info.mac] then
656                            for i=1,#systems do
657                                local zones = systems[i].zones
658                                if zones then
659                                    local summary = { time = status.time }
660                                    for i=1,#zones do
661                                        local zone = zones[i]
662                                        if validzonetypes[zone.zoneType] then
663                                            summary[#summary+1] = updatezone(presets,zone.name,zone)
664                                        end
665                                    end
666                                    return result(summary,"loading temperatures, %s")
667                                end
668                            end
669                        end
670                    end
671                end
672            end
673        end
674    end
675    return result(false,"loading temperatures, %s")
676end
677
678local function updatetemperatures(presets)
679    if validpresets(presets) then
680        local everythingname = presets.files.everything
681        local latestname     = presets.files.latest
682        local historyname    = presets.files.history
683        if (everythingname or latestname) and historyname then
684            gettemperatures(presets)
685            local t = loadtemperatures(presets)
686            if t then
687                local data = { }
688                for i=1,#t do
689                    local ti = t[i]
690                    data[ti.name] = ti.current
691                end
692                local history = loadhistory(historyname) or { }
693                setmoment(history,ostime(),data)
694                savedtable(historyname,history)
695                return result(t,"updating temperatures, %s")
696            end
697        end
698    end
699    return result(false,"updating temperatures, %s")
700end
701
702local function getzonestate(presets,name)
703    return validpresets(presets) and presets.data.states[name]
704end
705
706local f = replacer (
707    [[curl ]] ..
708    [[--silent --insecure ]] ..
709    [[-X PUT ]] ..
710    [[-H "Authorization: bearer %accesstoken%" ]] ..
711    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
712    [[-H "applicationId: %applicationid%" ]] ..
713    [[-H "Content-Type: application/json" ]] ..
714    [[-d "%[settings]%" ]] ..
715    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/temperatureZone/%zoneid%/heatSetpoint"]]
716)
717
718local function untilmidnight()
719    local t = osdate("*t")
720    t.hour = 23
721    t.min  = 59
722    t.sec  = 59
723    return osdate("%Y-%m-%dT%H:%M:%SZ",ostime(t))
724end
725
726local followschedule = {
727 -- HeatSetpointValue = 0,
728    SetpointMode      = "FollowSchedule",
729}
730
731local function setzonestate(presets,name,temperature,permanent)
732    local zone = findzone(presets,name)
733    if zone then
734        local m = followschedule
735        if type(temperature) == "number" and temperature > 0 then
736            if permanent then
737                m = {
738                    HeatSetpointValue = temperature,
739                    SetpointMode      = "PermanentOverride",
740                }
741            else
742                m = {
743                    HeatSetpointValue = temperature,
744                    SetpointMode      = "TemporaryOverride",
745                    TimeUntil         = untilmidnight(),
746                }
747            end
748        end
749        local s = f {
750            accesstoken   = presets.credentials.accesstoken,
751            applicationid = applicationid,
752            zoneid        = zone.zoneId,
753            settings      = jsontostring(m),
754        }
755        local r = s and resultof(s)
756        local t = r and jsontolua(r)
757-- inspect(r)
758-- inspect(t)
759        return result(t,"setting state of zone %a, %s",name)
760    end
761    return result(false,"setting state of zone %a, %s",name)
762end
763
764local function resetzonestate(presets,name)
765    setzonestate(presets,name)
766end
767
768--
769
770local function update(presets,noschedules)
771    local everything = geteverything(presets,noschedules)
772    if everything then
773        presets.data.everything = everything
774        return presets
775    end
776end
777
778local function initialize(filename)
779    local presets = loadpresets(filename)
780    if presets then
781        return update(presets)
782    end
783end
784
785local function off(presets,name)
786    local zone = presets and getzonestate(presets,name)
787    if zone then
788        setzonestate(presets,name,zone.lowest)
789    end
790end
791
792local function on(presets,name,temperature)
793    local zone = presets and getzonestate(presets,name)
794    if zone then
795        setzonestate(presets,name,temperature or zone.highest)
796    end
797end
798
799local function schedule(presets,name)
800    local zone = presets and getzonestate(presets,name)
801    if zone then
802        resetzonestate(presets,name)
803    end
804end
805
806local function permanent(presets,name)
807    local zone = presets and getzonestate(presets,name)
808    if zone then
809        setzonestate(presets,name,zone.lowest,true)
810    end
811end
812
813-- tasks
814
815local function settask(presets,when,tag,action)
816    if when == "tomorrow" then
817        local list = presets.scheduled
818        if not list then
819            list = loadtable(presets.files.schedules) or { }
820            presets.scheduled = list
821        end
822        if action then
823            list[tag] = {
824                time     = ostime() + 24*60*60,
825                done     = false,
826                category = category,
827                action   = action,
828                tag      = tag,
829            }
830        else
831            list[tag] = nil
832        end
833        savedtable(presets.files.schedules,list,false)
834    end
835end
836
837local function gettask(presets,when,tag)
838    if when == "tomorrow" then
839        local list = presets.scheduled
840        if not list then
841            list = loadtable(presets.files.schedules) or { }
842            presets.scheduled = list
843        end
844        return list[tag]
845    end
846end
847
848local function resettask(presets,when,tag)
849    settask(presets,when,tag)
850end
851
852local function checktasks(presets)
853    local list = presets.scheduled
854    if not list then
855        list = loadtable(presets.files.schedules) or { }
856        presets.scheduled = list
857    end
858    if list then
859        local t = osdate("*t")
860        local q = { }
861        for k, v in next, list do
862            local d = osdate("*t",v.time)
863            if not v.done and d.year == t.year and d.month == t.month and d.day == t.day then
864                local a = v.action
865                if type(a) == "function" then
866                    a()
867                end
868                v.done = true
869            end
870            if d.year <= t.year and d.month <= t.month and d.day < t.day then
871                q[k] = true
872            end
873        end
874        if next(q) then
875            for k, v in next, q do
876                list[q] = nil
877            end
878            savedtable(presets.files.schedules,list)
879        end
880        return list
881    end
882end
883
884-- predefined tasks
885
886local function settomorrow(presets,tag,action)
887    settask(presets,"tomorrow",tag,action)
888end
889
890local function resettomorrow(presets,tag)
891    settask(presets,"tomorrow",tag)
892end
893
894local function tomorrowset(presets,tag)
895    return gettask(presets,"tomorrow",tag) and true or false
896end
897
898--
899
900local evohome
901
902local function poller(presets)
903    --
904    if type(presets) ~= "string" then
905        report("invalid presets file")
906        os.exit()
907    end
908    report("loading presets from %a",presets)
909    local presets = loadpresets(presets)
910    if not validpresets(presets) then
911        report("invalid presets, aborting")
912        os.exit()
913    end
914    --
915    local actions = presets.files.actions
916    if type(actions) ~= "string" then
917        report("invalid actions file")
918        os.exit()
919    end
920    report("loading actions from %a",actions)
921    local actions = loadtable(actions)
922    if type(actions) ~= "table" then
923        report("invalid actions, aborting")
924        os.exit()
925    end
926    actions = actions.actions
927    if type(actions) ~= "table" then
928        report("invalid actions file, no actions subtable")
929        os.exit()
930    end
931    --
932    report("updating device status")
933    update(presets)
934    --
935    presets.report     = report
936    presets.evohome    = evohome
937    presets.results    = { }
938    --
939    function presets.getstate(name)
940        return getzonestate(presets,name)
941    end
942    function presets.tomorrowset(name)
943        return tomorrowset(presets,name)
944    end
945    --
946    local template = actions.template or presets.files.template
947    --
948    local process = function(t)
949        local category = t.category
950        local action   = t.action
951        if category and action then
952            local c = actions[category]
953            if c then
954                local a = c[action]
955                if type(a) == "function" then
956                    report("category %a, action %a, executing",category,action)
957                    presets.results.template = template -- can be overloaded by action
958                    a(presets)
959                    update(presets,true)
960                else
961                    report("category %a, action %a, invalid action, known: %, t",category,action,sortedkeys(c))
962                end
963            else
964                report("category %a, action %a, invalid category, known categories: %, t",category,action,sortedkeys(actions))
965            end
966        else
967         -- logs.report("invalid category and action")
968        end
969    end
970    --
971    local delay    = presets.delay or 10
972    local interval = 15 * 60 -- 15 minutes
973    local interval = 60 * 60 -- 60 minutes
974    local refresh  =  5 * 60
975    local passed   =  0
976    local step = function()
977        if passed > interval then
978            report("refreshing states, every %i seconds",interval)
979            -- todo: update stepwise as this also updates the schedules that we don't really
980            -- change often and definitely not in the middle of the night, so maybe just
981            -- update 9:00 12:00 15:00 18:00 21:00
982            update(presets)
983            passed = 0
984        else
985            passed = passed + delay
986        end
987        checktasks(presets)
988        return delay
989    end
990    --
991    presets.refreshtime = refresh
992    --
993    return step, process, presets
994end
995
996local function alloff(presets)
997    local zones = getzonenames(presets)
998    if zones then
999        for i=1,#zones do
1000            setzonestate(presets,zones[i],5,true)
1001        end
1002    end
1003end
1004
1005--
1006
1007evohome = {
1008    helpers = {
1009        getaccesstoken     = getaccesstoken,     -- presets
1010        getuserinfo        = getuserinfo,        -- presets
1011        getlocationinfo    = getlocationinfo,    -- presets
1012        getschedule        = getschedule,        -- presets, name
1013        --
1014        geteverything      = geteverything,      -- presets, noschedules
1015        gettemperatures    = gettemperatures,    -- presets
1016        getzonestate       = getzonestate,       -- presets, name
1017        setzonestate       = setzonestate,       -- presets, name, temperature
1018        resetzonestate     = resetzonestate,     -- presets, name
1019        getzonedata        = findzone,           -- presets, name
1020        getzonenames       = getzonenames,       -- presets
1021        --
1022        loadpresets        = loadpresets,        -- filename
1023        loadhistory        = loadhistory,        -- presets | filename
1024        loadeverything     = loadeverything,     -- presets | filename
1025        loadtemperatures   = loadtemperatures,   -- presets | filename
1026        --
1027        updatetemperatures = updatetemperatures, -- presets
1028    },
1029    actions= {
1030        initialize         = initialize,         -- filename
1031        update             = update,             -- presets
1032        --
1033        off                = off,                -- presets, name
1034        on                 = on,                 -- presets, name
1035        schedule           = schedule,           -- presets, name
1036        permanent          = permanent,          -- presets, name
1037        --
1038        alloff             = alloff,             -- presets
1039        --
1040        settomorrow        = settomorrow,        -- presets, tag, function
1041        resettomorrow      = resettomorrow,      -- presets, tag
1042        tomorrowset        = tomorrowset,        -- presets, tag
1043        --
1044        poller             = poller,             -- presets
1045    }
1046}
1047
1048if utilities then
1049    utilities.evohome = evohome
1050end
1051
1052-- local presets = evohome.helpers.loadpresets("c:/data/develop/domotica/code/evohome-presets.lua")
1053-- evohome.helpers.setzonestate(presets,"Voorkamer",22)
1054-- evohome.helpers.setzonestate(presets,"Voorkamer")
1055
1056return evohome
1057
1058