util-evo.lua /size: 37 Kb    last modification: 2025-02-21 11:03
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
222-- the token is kind of generic and shared
223
224-- 91db1612-73fd-4500-91b2-e63b069b185c
225
226local f = replacer (
227    [[curl ]] ..
228    [[--silent --insecure ]] ..
229    [[-X POST ]] ..
230    [[-H "Authorization: Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=" ]] ..
231    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
232    [[-d "Content-Type=application/x-www-form-urlencoded; charset=utf-8" ]] ..
233 -- [[-H "applicationId: %applicationid%" ]] ..
234    [[-d "Host=rs.alarmnet.com/" ]] ..
235    [[-d "Cache-Control=no-store no-cache" ]] ..
236    [[-d "Pragma=no-cache" ]] ..
237    [[-d "grant_type=password" ]] ..
238    [[-d "scope=EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account" ]] ..
239    [[-d "Username=%username%" ]] ..
240    [[-d "Password=%password%" ]] ..
241    [[-d "Connection=Keep-Alive" ]] ..
242    [["https://tccna.honeywell.com/Auth/OAuth/Token"]]
243)
244
245local function getaccesstoken(presets)
246    if validpresets(presets) then
247        local c = presets.credentials
248        local s = c and f {
249            username      = c.username,
250            password      = c.password,
251            applicationid = applicationid,
252        }
253 print(s)
254        local r = s and resultof(s)
255        local t = r and jsontolua(r)
256        return result(t,"getting access token %a")
257    end
258    return result(false,"getting access token %a")
259end
260
261local f = replacer (
262    [[curl ]] ..
263    [[--silent --insecure ]] ..
264    [[-H "Authorization: bearer %accesstoken%" ]] ..
265    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
266    [[-H "applicationId: %applicationid%" ]] ..
267    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/userAccount"]]
268)
269
270local function getuserinfo(presets)
271    if validpresets(presets) then
272        local c = presets.credentials
273        local s = c and f {
274            accesstoken   = c.accesstoken,
275            applicationid = c.applicationid,
276        }
277        local r = s and resultof(s)
278        local t = r and jsontolua(r)
279        return result(t,"getting user info for %a")
280    end
281    return result(false,"getting user info for %a")
282end
283
284local f = replacer (
285    [[curl ]] ..
286    [[--silent --insecure ]] ..
287    [[-H "Authorization: bearer %accesstoken%" ]] ..
288    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
289    [[-H "applicationId: %applicationid%" ]] ..
290    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/location/installationInfo?userId=%userid%&includeTemperatureControlSystems=True"]]
291)
292
293local function getlocationinfo(presets)
294    if validpresets(presets) then
295        local c = presets.credentials
296        local s = c and f {
297            accesstoken   = c.accesstoken,
298            applicationid = applicationid,
299            userid        = c.userid,
300        }
301        local r = s and resultof(s)
302        local t = r and jsontolua(r)
303        return result(t,"getting location info for %a")
304    end
305    return result(false,"getting location info for %a")
306end
307
308local f = replacer (
309    [[curl ]] ..
310    [[--silent --insecure ]] ..
311    [[-H "Authorization: bearer %accesstoken%" ]] ..
312    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
313    [[-H "applicationId: %applicationid%" ]] ..
314    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/temperatureZone/%zoneid%/schedule"]]
315)
316
317local function getschedule(presets,zonename)
318    if validpresets(presets) then
319        local zoneid = presets.data.zones[zonename].zoneId
320        if zoneid then
321            local c = presets.credentials
322            local s = c and f {
323                accesstoken   = c.accesstoken,
324                applicationid = applicationid,
325                zoneid        = zoneid,
326            }
327            local r = s and resultof(s)
328            local t = r and jsontolua(r)
329            return result(t,"getting schedule for zone %a, %s",zonename or "?")
330        end
331    end
332    return result(false,"getting schedule for zone %a, %s",zonename or "?")
333end
334
335local f = replacer (
336    [[curl ]] ..
337    [[--silent --insecure ]] ..
338    [[-H "Authorization: bearer %accesstoken%" ]] ..
339    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
340    [[-H "applicationId: %applicationid%" ]] ..
341    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/location/%locationid%/status?includeTemperatureControlSystems=True" ]]
342)
343
344local function getstatus(presets,locationid,locationname)
345    if locationid and validpresets(presets) then
346        local c = presets.credentials
347        local s = c and f {
348            accesstoken   = c.accesstoken,
349            applicationid = applicationid,
350            locationid    = locationid,
351        }
352        local r = s and resultof(s)
353        local t = r and jsontolua(r)
354        return result(t and t.gateways and t,"getting status for location %a, %s",locationname or "?")
355    end
356    return result(false,"getting status for location %a, %s",locationname or "?")
357end
358
359local function validated(presets)
360    if validpresets(presets) then
361        local data = getlocationinfo(presets)
362        if data and type(data) == "table" and data[1] and data[1].locationInfo then
363            return true
364        else
365            local data = getaccesstoken(presets)
366            if data then
367                presets.credentials.accesstoken = data.access_token
368                local data = getuserinfo(presets)
369                if data then
370                    presets.credentials.userid = data.userId
371                    return true
372                end
373            end
374        end
375    end
376end
377
378local function findzone(presets,name)
379    if not presets then
380        return
381    end
382    local data = presets.data
383    if not data then
384        return
385    end
386    local usedzones = data.zones
387    return usedzones and usedzones[name]
388end
389
390local function getzonenames(presets)
391    if not presets then
392        return { }
393    end
394    local data = presets.data
395    if not data then
396        return { }
397    end
398    local t = sortedkeys(data.zones or { })
399    for i=1,#t do
400        t[i] = lower(t[i])
401    end
402    return t
403end
404
405local function gettargets(zone) -- maybe also for a day
406    local schedule = zone.schedule
407    local min      = false
408    local max      = false
409    if schedule then
410        local schedules = schedule.dailySchedules
411        if schedules then
412            for i=1,#schedules do
413                local switchpoints = schedules[i].switchpoints
414                for i=1,#switchpoints do
415                    local m = switchpoints[i].temperature
416                    if not min or m < min then
417                        min = m
418                    end
419                    if not max or m > max then
420                        max = m
421                    end
422                end
423            end
424        else
425            report("zone %a has no schedule",name)
426        end
427    end
428    return min, max
429end
430
431local function updatezone(presets,name,zone)
432    if not zone then
433        zone = findzone(presets,name)
434    end
435    if zone then
436        local oldtarget = presets.data.states[name]
437        local min = zone.heatSetpointCapabilities.minHeatSetpoint or  5
438        local max = zone.heatSetpointCapabilities.maxHeatSetpoint or 12
439        local mintarget, maxtarget = gettargets(zone)
440        -- todo: maybe get these from presets
441        if mintarget == false then
442            if min < 5 then
443                mintarget = 5
444             -- report("zone %a, min target limited to %a",name,mintarget)
445            else
446                mintarget = min
447            end
448        end
449        if maxtarget == false then
450            if max > 18.5 then
451                maxtarget = 18.5
452             -- report("zone %a, max target limited to %a",name,maxtarget)
453            else
454                maxtarget = max
455            end
456        end
457        local current = zone.temperatureStatus.temperature or 0
458        local target  = zone.heatSetpointStatus.targetTemperature
459        local mode    = zone.heatSetpointStatus.setpointMode
460        local state   = (mode == "FollowSchedule"                            and "schedule" ) or
461                        (mode == "PermanentOverride" and target <= mintarget and "permanent") or
462                        (mode == "TemporaryOverride" and target <= mintarget and "off"      ) or
463                        (mode == "TemporaryOverride" and target >= maxtarget and "on"       ) or
464                        (                                                        "unknown"  )
465        local t = {
466            name      = zone.name,
467            id        = zone.zoneId,
468            schedule  = zone.schedule,
469            mode      = mode,
470            current   = current,
471            target    = target,
472            min       = min,
473            max       = max,
474            state     = state,
475            lowest    = mintarget,
476            highest   = maxtarget,
477        }
478     -- report("zone %a, current %a, target %a",name,current,target)
479        presets.data.states[name] = t
480        return t
481    end
482end
483
484
485local function geteverything(presets,noschedules)
486    if validated(presets) then
487        local data = getlocationinfo(presets)
488        if data then
489            local usedgateways = presets.data.gateways
490            local usedzones    = presets.data.zones
491            for i=1,#data do
492                local gateways     = data[i].gateways
493                local locationinfo = data[i].locationInfo
494                local locationid   = locationinfo and locationinfo.locationId
495                if gateways and locationid then
496                    local status = getstatus(presets,locationid,locationinfo.name)
497                    if status then
498                        for i=1,#gateways do
499                            local gatewaystatus  = status.gateways[i]
500                            local gatewayinfo    = gateways[i]
501                            local gatewaysystems = gatewayinfo.temperatureControlSystems
502                            local info           = gatewayinfo.gatewayInfo
503                            local statussystems  = gatewaystatus.temperatureControlSystems
504                            if gatewaysystems and statussystems and info then
505                                local mac = info.mac
506                                if usedgateways[mac] then
507                                    report("%s gateway with mac address %a","using",mac)
508                                    for j=1,#gatewaysystems do
509                                        local gatewayzones = gatewaysystems[j].zones
510                                        local zonestatus   = statussystems[j].zones
511                                        if gatewayzones and zonestatus then
512                                            for k=1,#gatewayzones do
513                                                local zonestatus  = zonestatus[k]
514                                                local gatewayzone = gatewayzones[k]
515                                                if zonestatus and gatewayzone then
516                                                    local zonename = zonestatus.name
517                                                    local zoneid   = zonestatus.zoneId
518                                                    if validzonetypes[gatewayzone.zoneType] and zonename == gatewayzone.name then
519                                                        gatewayzone.heatSetpointStatus = zonestatus.heatSetpointStatus
520                                                        gatewayzone.temperatureStatus  = zonestatus.temperatureStatus
521                                                        local zonestatus = usedzones[zonename] -- findzone(states,zonename)
522                                                        local schedule   = zonestatus and zonestatus.schedule
523                                                        usedzones[zonename] = gatewayzone
524                                                        if schedule and noschedules then
525                                                            gatewayzone.schedule = schedule
526                                                        else
527                                                            gatewayzone.schedule = getschedule(presets,zonename)
528                                                        end
529                                                        updatezone(presets,zonename,gatewayzone)
530                                                    end
531                                                end
532                                            end
533                                        end
534                                    end
535                                else
536                                    report("%s gateway with mac address %a","skipping",mac)
537                                end
538                            end
539                        end
540                    end
541                end
542            end
543            savedtable(presets.files.everything,data)
544            return result(data,"getting everything, %s")
545        end
546    end
547    return result(false,"getting everything, %s")
548end
549
550local function gettemperatures(presets)
551    if validated(presets) then
552        local data = loadeverything(presets)
553        if not data or not next(data) then
554            data = geteverything(presets)
555        end
556        if data then
557            local updated = false
558            for i=1,#data do
559                local gateways     = data[i].gateways
560                local locationinfo = data[i].locationInfo
561                if locationinfo then
562                    local locationid = locationinfo.locationId
563                    if gateways then
564                        local status = getstatus(presets,locationid,locationinfo.name)
565                        if status then
566                            for i=1,#gateways do
567                                local g = status.gateways[i]
568                                local gateway = gateways[i]
569                                local systems = gateway.temperatureControlSystems
570                                if systems then
571                                    local s = g.temperatureControlSystems
572                                    for i=1,#systems do
573                                        local zones = systems[i].zones
574                                        if zones then
575                                            local z = s[i].zones
576                                            for i=1,#zones do
577                                                local zone = zones[i]
578                                                if validzonetypes[zone.zoneType] then
579                                                    local z = z[i]
580                                                    if z.name == zone.name then
581                                                        zone.temperatureStatus = z.temperatureStatus
582                                                        updated = true
583                                                    end
584                                                end
585                                            end
586                                        end
587                                    end
588                                end
589                            end
590                        end
591                    else
592                        report("no gateways")
593                    end
594                else
595                    report("no location info")
596                end
597            end
598            if updated then
599                data.time = ostime()
600                savedtable(presets.files.latest,data)
601            end
602            return result(data,"getting temperatures, %s")
603        end
604    end
605    return result(false,"getting temperatures, %s")
606end
607
608local function setmoment(target,time,data)
609    if not time then
610        time = ostime()
611    end
612    local t = osdate("*t",time )
613    local c_year, c_month, c_day, c_hour, c_minute = t.year, t.month, t.day, t.hour, t.min
614    --
615    local years   = target.years    if not years   then years   = { } target.years    = years   end
616    local d_year  = years[c_year]   if not d_year  then d_year  = { } years[c_year]   = d_year  end
617    local months  = d_year.months   if not months  then months  = { } d_year.months   = months  end
618    local d_month = months[c_month] if not d_month then d_month = { } months[c_month] = d_month end
619    local days    = d_month.days    if not days    then days    = { } d_month.days    = days    end
620    local d_day   = days[c_day]     if not d_day   then d_day   = { } days[c_day]     = d_day   end
621    local hours   = d_day.hours     if not hours   then hours   = { } d_day.hours     = hours   end
622    local d_hour  = hours[c_hour]   if not d_hour  then d_hour  = { } hours[c_hour]   = d_hour  end
623    --
624    c_minute = div(c_minute,15) + 1
625    --
626    local d_last = d_hour[c_minute]
627    if d_last then
628        for k, v in next, data do
629            local d = d_last[k]
630            if d then
631                data[k] = (d + v) / 2
632            end
633        end
634    end
635    d_hour[c_minute] = data
636    --
637    target.lasttime = {
638        year   = c_year,
639        month  = c_month,
640        day    = c_day,
641        hour   = c_hour,
642        minute = c_minute,
643    }
644end
645
646local function loadtemperatures(presets)
647    if validpresets(presets) then
648        local status = loadlatest(presets)
649        if not status or not next(status) then
650            status = loadeverything(presets)
651        end
652        if status then
653            local usedgateways = presets.data.gateways
654            for i=1,#status do
655                local gateways = status[i].gateways
656                if gateways then
657                    for i=1,#gateways do
658                        local gatewayinfo = gateways[i]
659                        local systems     = gatewayinfo.temperatureControlSystems
660                        local info        = gatewayinfo.gatewayInfo
661                        if systems and info and usedgateways[info.mac] then
662                            for i=1,#systems do
663                                local zones = systems[i].zones
664                                if zones then
665                                    local summary = { time = status.time }
666                                    for i=1,#zones do
667                                        local zone = zones[i]
668                                        if validzonetypes[zone.zoneType] then
669                                            summary[#summary+1] = updatezone(presets,zone.name,zone)
670                                        end
671                                    end
672                                    return result(summary,"loading temperatures, %s")
673                                end
674                            end
675                        end
676                    end
677                end
678            end
679        end
680    end
681    return result(false,"loading temperatures, %s")
682end
683
684local function updatetemperatures(presets)
685    if validpresets(presets) then
686        local everythingname = presets.files.everything
687        local latestname     = presets.files.latest
688        local historyname    = presets.files.history
689        if (everythingname or latestname) and historyname then
690            gettemperatures(presets)
691            local t = loadtemperatures(presets)
692            if t then
693                local data = { }
694                for i=1,#t do
695                    local ti = t[i]
696                    data[ti.name] = ti.current
697                end
698                local history = loadhistory(historyname) or { }
699                setmoment(history,ostime(),data)
700                savedtable(historyname,history)
701                return result(t,"updating temperatures, %s")
702            end
703        end
704    end
705    return result(false,"updating temperatures, %s")
706end
707
708local function getzonestate(presets,name)
709    return validpresets(presets) and presets.data.states[name]
710end
711
712local f = replacer (
713    [[curl ]] ..
714    [[--silent --insecure ]] ..
715    [[-X PUT ]] ..
716    [[-H "Authorization: bearer %accesstoken%" ]] ..
717    [[-H "Accept: application/json, application/xml, text/json, text/x-json, text/javascript, text/xml" ]] ..
718    [[-H "applicationId: %applicationid%" ]] ..
719    [[-H "Content-Type: application/json" ]] ..
720    [[-d "%[settings]%" ]] ..
721    [["https://tccna.honeywell.com/WebAPI/emea/api/v1/temperatureZone/%zoneid%/heatSetpoint"]]
722)
723
724local function untilmidnight()
725    local t = osdate("*t")
726    t.hour = 23
727    t.min  = 59
728    t.sec  = 59
729    return osdate("%Y-%m-%dT%H:%M:%SZ",ostime(t))
730end
731
732local followschedule = {
733 -- HeatSetpointValue = 0,
734    SetpointMode      = "FollowSchedule",
735}
736
737local function setzonestate(presets,name,temperature,permanent)
738    local zone = findzone(presets,name)
739    if zone then
740        local m = followschedule
741        if type(temperature) == "number" and temperature > 0 then
742            if permanent then
743                m = {
744                    HeatSetpointValue = temperature,
745                    SetpointMode      = "PermanentOverride",
746                }
747            else
748                m = {
749                    HeatSetpointValue = temperature,
750                    SetpointMode      = "TemporaryOverride",
751                    TimeUntil         = untilmidnight(),
752                }
753            end
754        end
755        local s = f {
756            accesstoken   = presets.credentials.accesstoken,
757            applicationid = applicationid,
758            zoneid        = zone.zoneId,
759            settings      = jsontostring(m),
760        }
761        local r = s and resultof(s)
762        local t = r and jsontolua(r)
763-- inspect(r)
764-- inspect(t)
765        return result(t,"setting state of zone %a, %s",name)
766    end
767    return result(false,"setting state of zone %a, %s",name)
768end
769
770local function resetzonestate(presets,name)
771    setzonestate(presets,name)
772end
773
774--
775
776local function update(presets,noschedules)
777    local everything = geteverything(presets,noschedules)
778    if everything then
779        presets.data.everything = everything
780        return presets
781    end
782end
783
784local function initialize(filename)
785    local presets = loadpresets(filename)
786    if presets then
787        return update(presets)
788    end
789end
790
791local function off(presets,name)
792    local zone = presets and getzonestate(presets,name)
793    if zone then
794        setzonestate(presets,name,zone.lowest)
795    end
796end
797
798local function on(presets,name,temperature)
799    local zone = presets and getzonestate(presets,name)
800    if zone then
801        setzonestate(presets,name,temperature or zone.highest)
802    end
803end
804
805local function schedule(presets,name)
806    local zone = presets and getzonestate(presets,name)
807    if zone then
808        resetzonestate(presets,name)
809    end
810end
811
812local function permanent(presets,name)
813    local zone = presets and getzonestate(presets,name)
814    if zone then
815        setzonestate(presets,name,zone.lowest,true)
816    end
817end
818
819-- tasks
820
821local function settask(presets,when,tag,action)
822    if when == "tomorrow" then
823        local list = presets.scheduled
824        if not list then
825            list = loadtable(presets.files.schedules) or { }
826            presets.scheduled = list
827        end
828        if action then
829            list[tag] = {
830                time     = ostime() + 24*60*60,
831                done     = false,
832                category = category,
833                action   = action,
834                tag      = tag,
835            }
836        else
837            list[tag] = nil
838        end
839        savedtable(presets.files.schedules,list,false)
840    end
841end
842
843local function gettask(presets,when,tag)
844    if when == "tomorrow" then
845        local list = presets.scheduled
846        if not list then
847            list = loadtable(presets.files.schedules) or { }
848            presets.scheduled = list
849        end
850        return list[tag]
851    end
852end
853
854local function resettask(presets,when,tag)
855    settask(presets,when,tag)
856end
857
858local function checktasks(presets)
859    local list = presets.scheduled
860    if not list then
861        list = loadtable(presets.files.schedules) or { }
862        presets.scheduled = list
863    end
864    if list then
865        local t = osdate("*t")
866        local q = { }
867        for k, v in next, list do
868            local d = osdate("*t",v.time)
869            if not v.done and d.year == t.year and d.month == t.month and d.day == t.day then
870                local a = v.action
871                if type(a) == "function" then
872                    a()
873                end
874                v.done = true
875            end
876            if d.year <= t.year and d.month <= t.month and d.day < t.day then
877                q[k] = true
878            end
879        end
880        if next(q) then
881            for k, v in next, q do
882                list[q] = nil
883            end
884            savedtable(presets.files.schedules,list)
885        end
886        return list
887    end
888end
889
890-- predefined tasks
891
892local function settomorrow(presets,tag,action)
893    settask(presets,"tomorrow",tag,action)
894end
895
896local function resettomorrow(presets,tag)
897    settask(presets,"tomorrow",tag)
898end
899
900local function tomorrowset(presets,tag)
901    return gettask(presets,"tomorrow",tag) and true or false
902end
903
904--
905
906local evohome
907
908local function poller(presets)
909    --
910    if type(presets) ~= "string" then
911        report("invalid presets file")
912        os.exit()
913    end
914    report("loading presets from %a",presets)
915    local presets = loadpresets(presets)
916    if not validpresets(presets) then
917        report("invalid presets, aborting")
918        os.exit()
919    end
920    --
921    local actions = presets.files.actions
922    if type(actions) ~= "string" then
923        report("invalid actions file")
924        os.exit()
925    end
926    report("loading actions from %a",actions)
927    local actions = loadtable(actions)
928    if type(actions) ~= "table" then
929        report("invalid actions, aborting")
930        os.exit()
931    end
932    actions = actions.actions
933    if type(actions) ~= "table" then
934        report("invalid actions file, no actions subtable")
935        os.exit()
936    end
937    --
938    report("updating device status")
939    update(presets)
940    --
941    presets.report     = report
942    presets.evohome    = evohome
943    presets.results    = { }
944    --
945    function presets.getstate(name)
946        return getzonestate(presets,name)
947    end
948    function presets.tomorrowset(name)
949        return tomorrowset(presets,name)
950    end
951    --
952    local template = actions.template or presets.files.template
953    --
954    local process = function(t)
955        local category = t.category
956        local action   = t.action
957        if category and action then
958            local c = actions[category]
959            if c then
960                local a = c[action]
961                if type(a) == "function" then
962                    report("category %a, action %a, executing",category,action)
963                    presets.results.template = template -- can be overloaded by action
964                    a(presets)
965                    update(presets,true)
966                else
967                    report("category %a, action %a, invalid action, known: %, t",category,action,sortedkeys(c))
968                end
969            else
970                report("category %a, action %a, invalid category, known categories: %, t",category,action,sortedkeys(actions))
971            end
972        else
973         -- logs.report("invalid category and action")
974        end
975    end
976    --
977    local delay    = presets.delay or 10
978    local interval = 15 * 60 -- 15 minutes
979    local interval = 60 * 60 -- 60 minutes
980    local refresh  =  5 * 60
981    local passed   =  0
982    local step = function()
983        if passed > interval then
984            report("refreshing states, every %i seconds",interval)
985            -- todo: update stepwise as this also updates the schedules that we don't really
986            -- change often and definitely not in the middle of the night, so maybe just
987            -- update 9:00 12:00 15:00 18:00 21:00
988            update(presets)
989            passed = 0
990        else
991            passed = passed + delay
992        end
993        checktasks(presets)
994        return delay
995    end
996    --
997    presets.refreshtime = refresh
998    --
999    return step, process, presets
1000end
1001
1002local function alloff(presets)
1003    local zones = getzonenames(presets)
1004    if zones then
1005        for i=1,#zones do
1006            setzonestate(presets,zones[i],5,true)
1007        end
1008    end
1009end
1010
1011--
1012
1013evohome = {
1014    helpers = {
1015        getaccesstoken     = getaccesstoken,     -- presets
1016        getuserinfo        = getuserinfo,        -- presets
1017        getlocationinfo    = getlocationinfo,    -- presets
1018        getschedule        = getschedule,        -- presets, name
1019        --
1020        geteverything      = geteverything,      -- presets, noschedules
1021        gettemperatures    = gettemperatures,    -- presets
1022        getzonestate       = getzonestate,       -- presets, name
1023        setzonestate       = setzonestate,       -- presets, name, temperature
1024        resetzonestate     = resetzonestate,     -- presets, name
1025        getzonedata        = findzone,           -- presets, name
1026        getzonenames       = getzonenames,       -- presets
1027        --
1028        loadpresets        = loadpresets,        -- filename
1029        loadhistory        = loadhistory,        -- presets | filename
1030        loadeverything     = loadeverything,     -- presets | filename
1031        loadtemperatures   = loadtemperatures,   -- presets | filename
1032        --
1033        updatetemperatures = updatetemperatures, -- presets
1034    },
1035    actions= {
1036        initialize         = initialize,         -- filename
1037        update             = update,             -- presets
1038        --
1039        off                = off,                -- presets, name
1040        on                 = on,                 -- presets, name
1041        schedule           = schedule,           -- presets, name
1042        permanent          = permanent,          -- presets, name
1043        --
1044        alloff             = alloff,             -- presets
1045        --
1046        settomorrow        = settomorrow,        -- presets, tag, function
1047        resettomorrow      = resettomorrow,      -- presets, tag
1048        tomorrowset        = tomorrowset,        -- presets, tag
1049        --
1050        poller             = poller,             -- presets
1051    }
1052}
1053
1054if utilities then
1055    utilities.evohome = evohome
1056end
1057
1058-- local presets = evohome.helpers.loadpresets("c:/data/develop/domotica/evohome/evohome-presets.lua")
1059-- inspect(evohome.helpers.geteverything(presets))
1060-- evohome.helpers.setzonestate(presets,"Voorkamer",22)
1061-- evohome.helpers.setzonestate(presets,"Voorkamer")
1062-- inspect(presets)
1063
1064return evohome
1065
1066