util-you.lua /size: 14 Kb    last modification: 2020-07-01 14:35
1if not modules then modules = { } end modules ['util-you'] = {
2    version   = 1.002,
3    comment   = "library for fetching data from youless kwh meter polling device",
4    author    = "Hans Hagen, PRAGMA-ADE, Hasselt NL",
5    copyright = "PRAGMA ADE",
6    license   = "see context related readme files"
7}
8
9-- See mtx-youless.lua and s-youless.mkiv for examples of usage.
10--
11-- todo: already calculate min, max and average per hour and discard
12--       older data, or maybe a condense option
13--
14-- maybe just a special parser but who cares about speed here
15--
16-- curl -c pw.txt http://192.168.2.50/L?w=pwd
17-- curl -b pw.txt http://192.168.2.50/V?...
18--
19-- the socket library barks on an (indeed) invalid header ... unfortunately we cannot
20-- pass a password with each request ... although the youless is a rather nice gadget,
21-- the weak part is in the http polling
22
23require("util-jsn")
24
25-- the library variant:
26
27utilities         = utilities or { }
28local youless     = { }
29utilities.youless = youless
30
31local lpegmatch  = lpeg.match
32local formatters = string.formatters
33
34local tonumber, type, next = tonumber, type, next
35
36local round, div = math.round, math.div
37local osdate, ostime = os.date, os.time
38
39local report = logs.reporter("youless")
40local trace  = false
41
42-- dofile("http.lua")
43
44local http = socket.http
45
46-- f=j : json
47
48local f_password    = formatters["http://%s/L?w=%s"]
49
50local f_fetchers = {
51    electricity = formatters["http://%s/V?%s=%i&f=j"],
52    gas         = formatters["http://%s/W?%s=%i&f=j"],
53    pulse       = formatters["http://%s/Z?%s=%i&f=j"],
54}
55
56local function fetch(url,password,what,i,category)
57    local fetcher = f_fetchers[category or "electricity"]
58    if not fetcher then
59        report("invalid fetcher %a",category)
60    else
61        local url     = fetcher(url,what,i)
62        local data, h = http.request(url)
63        local result  = data and utilities.json.tolua(data)
64        return result
65    end
66end
67
68-- "123" " 23" "  1,234"
69
70local tovalue = lpeg.Cs((lpeg.R("09") + lpeg.P(1)/"")^1) / tonumber
71
72-- "2013-11-12T06:40:00"
73
74local totime = (lpeg.C(4) / tonumber) * lpeg.P("-")
75             * (lpeg.C(2) / tonumber) * lpeg.P("-")
76             * (lpeg.C(2) / tonumber) * lpeg.P("T")
77             * (lpeg.C(2) / tonumber) * lpeg.P(":")
78             * (lpeg.C(2) / tonumber) * lpeg.P(":")
79             * (lpeg.C(2) / tonumber)
80
81local function collapsed(data,dirty)
82    for list, parent in next, dirty do
83        local t, n = { }, { }
84        for k, v in next, list do
85            local d = div(k,10) * 10
86            t[d] = (t[d] or 0) + v
87            n[d] = (n[d] or 0) + 1
88        end
89        for k, v in next, t do
90            t[k] = round(t[k]/n[k])
91        end
92        parent[1][parent[2]] = t
93    end
94    return data
95end
96
97local function get(url,password,what,step,data,option,category)
98    if not data then
99        data = { }
100    end
101    local dirty = { }
102    while true do
103        local d = fetch(url,password,what,step,category)
104        local v = d and d.val
105        if v and #v > 0 then
106            local c_year, c_month, c_day, c_hour, c_minute, c_seconds = lpegmatch(totime,d.tm)
107            if c_year and c_seconds then
108                local delta = tonumber(d.dt)
109                local tnum = ostime {
110                    year  = c_year,
111                    month = c_month,
112                    day   = c_day,
113                    hour  = c_hour,
114                    min   = c_minute,
115                    sec   = c_seconds,
116                }
117                for i=1,#v do
118                    local vi = v[i]
119                    if vi ~= "*" then
120                        local newvalue = lpegmatch(tovalue,vi)
121                        if newvalue then
122                            local t = tnum + (i-1)*delta
123                         -- local current = osdate("%Y-%m-%dT%H:%M:%S",t)
124                         -- local c_year, c_month, c_day, c_hour, c_minute, c_seconds = lpegmatch(totime,current)
125                            local c = osdate("*t",tnum + (i-1)*delta)
126                            local c_year    = c.year
127                            local c_month   = c.month
128                            local c_day     = c.day
129                            local c_hour    = c.hour
130                            local c_minute  = c.min
131                            local c_seconds = c.sec
132                            if c_year and c_seconds then
133                                local years   = data.years      if not years   then years   = { } data.years      = years   end
134                                local d_year  = years[c_year]   if not d_year  then d_year  = { } years[c_year]   = d_year  end
135                                local months  = d_year.months   if not months  then months  = { } d_year.months   = months  end
136                                local d_month = months[c_month] if not d_month then d_month = { } months[c_month] = d_month end
137                                local days    = d_month.days    if not days    then days    = { } d_month.days    = days    end
138                                local d_day   = days[c_day]     if not d_day   then d_day   = { } days[c_day]     = d_day   end
139                                if option == "average" or option == "total" then
140                                    if trace then
141                                        local oldvalue = d_day[option]
142                                        if oldvalue and oldvalue ~= newvalue then
143                                            report("category %s, step %i, time %s: old %s %s updated to %s",category,step,osdate("%Y-%m-%dT%H:%M:%S",t),option,oldvalue,newvalue)
144                                        end
145                                    end
146                                    d_day[option] = newvalue
147                                elseif option == "value" then
148                                    local hours  = d_day.hours   if not hours  then hours  = { } d_day.hours   = hours  end
149                                    local d_hour = hours[c_hour] if not d_hour then d_hour = { } hours[c_hour] = d_hour end
150                                    if trace then
151                                        local oldvalue = d_hour[c_minute]
152                                        if oldvalue and oldvalue ~= newvalue then
153                                            report("category %s, step %i, time %s: old %s %s updated to %s",category,step,osdate("%Y-%m-%dT%H:%M:%S",t),"value",oldvalue,newvalue)
154                                        end
155                                    end
156                                    d_hour[c_minute] = newvalue
157                                    if not dirty[d_hour] then
158                                        dirty[d_hour] = { hours, c_hour }
159                                    end
160                                else
161                                    -- can't happen
162                                end
163                            end
164                        end
165                    end
166                end
167            end
168        else
169            return collapsed(data,dirty)
170        end
171        step = step + 1
172    end
173    return collapsed(data,dirty)
174end
175
176-- day of month (kwh)
177--     url = http://192.168.1.14/V?m=2
178--     m = the number of month (jan = 1, feb = 2, ..., dec = 12)
179
180-- hour of day (watt)
181--     url = http://192.168.1.14/V?d=1
182--     d = the number of days ago (today = 0, yesterday = 1, etc.)
183
184-- 10 minutes (watt)
185--     url = http://192.168.1.14/V?w=1
186--     w = 1 for the interval now till 8 hours ago.
187--     w = 2 for the interval 8 till 16 hours ago.
188--     w = 3 for the interval 16 till 24 hours ago.
189
190-- 1 minute (watt)
191--     url = http://192.168.1.14/V?h=1
192--     h = 1 for the interval now till 30 minutes ago.
193--     h = 2 for the interval 30 till 60 minutes ago
194
195function youless.collect(specification)
196    if type(specification) ~= "table" then
197        return
198    end
199    local host     = specification.host     or ""
200    local data     = specification.data     or { }
201    local filename = specification.filename or ""
202    local variant  = specification.variant  or "kwh"
203    local detail   = specification.detail   or false
204    local nobackup = specification.nobackup or false
205    local password = specification.password or ""
206    local oldstuff = false
207    if host == "" then
208        return
209    end
210    if filename == "" then
211        return
212    else
213        data = table.load(filename) or data
214    end
215    if variant == "electricity" then
216        get(host,password,"m",1,data,"total","electricity")
217        if oldstuff then
218            get(host,password,"d",1,data,"average","electricity")
219        end
220        get(host,password,"w",1,data,"value","electricity")
221        if detail then
222            get(host,password,"h",1,data,"value","electricity") -- todo: get this for calculating the precise max
223        end
224    elseif variant == "pulse" then
225        -- It looks like the 'd' option returns the wrong values or at least not the same sort
226        -- as the other ones, so we calculate the means ourselves. And 'w' is not consistent with
227        -- that too, so ...
228        get(host,password,"m",1,data,"total","pulse")
229        if oldstuff then
230            get(host,password,"d",1,data,"average","pulse")
231        end
232        detail = true
233        get(host,password,"w",1,data,"value","pulse")
234        if detail then
235            get(host,password,"h",1,data,"value","pulse")
236        end
237    elseif variant == "gas" then
238        get(host,password,"m",1,data,"total","gas")
239        if oldstuff then
240            get(host,password,"d",1,data,"average","gas")
241        end
242        get(host,password,"w",1,data,"value","gas")
243        if detail then
244            get(host,password,"h",1,data,"value","gas")
245        end
246    else
247        return
248    end
249    local path = file.dirname(filename)
250    local base = file.basename(filename)
251    data.variant = variant
252    data.host    = host
253    data.updated = os.now()
254    if nobackup then
255        -- saved but with checking
256        local tempname = file.join(path,"youless.tmp")
257        table.save(tempname,data)
258        local check = table.load(tempname)
259        if type(check) == "table" then
260            local keepname = file.replacesuffix(filename,"old")
261            os.remove(keepname)
262            if lfs.isfile(keepname) then
263                report("error in removing %a",keepname)
264            else
265                os.rename(filename,keepname)
266                os.rename(tempname,filename)
267            end
268        else
269            report("error in saving %a",tempname)
270        end
271    else
272        local keepname = file.join(path,formatters["%s-%s"](os.date("%Y-%m-%d-%H-%M-%S",os.time()),base))
273        os.rename(filename,keepname)
274        if lfs.isfile(filename) then
275            report("error in renaming %a",filename)
276        else
277            table.save(filename,data)
278        end
279    end
280    return data
281end
282
283-- local data = youless.collect {
284--     host     = "192.168.2.50",
285--     variant  = "electricity",
286--     category = "electricity",
287--     filename = "youless-electricity.lua"
288-- }
289--
290-- inspect(data)
291
292-- local data = youless.collect {
293--     host     = "192.168.2.50",
294--     variant  = "pulse",
295--     category = "electricity",
296--     filename = "youless-pulse.lua"
297-- }
298--
299-- inspect(data)
300
301-- local data = youless.collect {
302--     host     = "192.168.2.50",
303--     variant  = "gas",
304--     category = "gas",
305--     filename = "youless-gas.lua"
306-- }
307--
308-- inspect(data)
309
310-- We remain compatible so we stick to electricity and not unit fields.
311
312function youless.analyze(data)
313    if type(data) == "string" then
314        data = table.load(data)
315    end
316    if type(data) ~= "table" then
317        return false, "no data"
318    end
319    if not data.years then
320        return false, "no years"
321    end
322    local variant = data.variant
323    local unit, maxunit
324    if variant == "electricity" or variant == "watt" then
325        unit    = "watt"
326        maxunit = "maxwatt"
327    elseif variant == "gas" then
328        unit    = "liters"
329        maxunit = "maxliters"
330    elseif variant == "pulse" then
331        unit    = "watt"
332        maxunit = "maxwatt"
333    else
334        return false, "invalid variant"
335    end
336    for y, year in next, data.years do
337        local a_year, n_year, m_year = 0, 0, 0
338        if year.months then
339            for m, month in next, year.months do
340                local a_month, n_month = 0, 0
341                if month.days then
342                    for d, day in next, month.days do
343                        local a_day, n_day = 0, 0
344                        if day.hours then
345                            for h, hour in next, day.hours do
346                                local a_hour, n_hour, m_hour = 0, 0, 0
347                                for k, v in next, hour do
348                                    if type(k) == "number" then
349                                        a_hour = a_hour + v
350                                        n_hour = n_hour + 1
351                                        if v > m_hour then
352                                            m_hour = v
353                                        end
354                                    end
355                                end
356                                n_day = n_day + n_hour
357                                a_day = a_day + a_hour
358                                hour[maxunit] = m_hour
359                                hour[unit]    = a_hour / n_hour
360                                if m_hour > m_year then
361                                    m_year = m_hour
362                                end
363                            end
364                        end
365                        if n_day > 0 then
366                            a_month = a_month + a_day
367                            n_month = n_month + n_day
368                            day[unit] = a_day / n_day
369                        else
370                            day[unit] = 0
371                        end
372                    end
373                end
374                if n_month > 0 then
375                    a_year = a_year + a_month
376                    n_year = n_year + n_month
377                    month[unit] = a_month / n_month
378                else
379                    month[unit] = 0
380                end
381            end
382        end
383        if n_year > 0 then
384            year[unit]    = a_year / n_year
385            year[maxunit] = m_year
386        else
387            year[unit]    = 0
388            year[maxunit] = 0
389        end
390    end
391    return data
392end
393