-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathL_Reactor.lua
7533 lines (7110 loc) · 289 KB
/
L_Reactor.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[
L_Reactor.lua - Core module for Reactor
Copyright 2018-2022 Patrick H. Rigney, All Rights Reserved.
This file is part of Reactor.
RESTRICTED USE LICENSE
Reactor is not open source or public domain, and its author reserves all
rights, including copyright. That said, you are granted a royalty-free
license to use Reactor on any Vera or openLuup system you own for the
purpose of creating automations. Except with express prior permission of
its author, you may not distribute it in whole or in part. You may not
reverse engineer it, or produce derivative works from its source code and
original files and data, but any automations and configuration data you
create for use with Reactor are, of course, yours to do with as you please
(these are not considered derivative works here). The public storage and
display of Reactor's source code on Github or any other medium is a
requirement for its distribution and use in the environments in which it is
designed to operate, and Reactor shall not be construed as having been
"published" into the public domain or "Open Source" or for any other
purpose or use that is inconsistent with this license. Reactor is offered
"AS-IS" and "AS-AVAILABLE" together with any and all defects; all
warranties, including but not limited to all express or implied warranties
of fitness, are hereby disclaimed, and you agree to indemnify and hold
harmless the author from any cause arising, of whatever nature, that may
result for your use or inability to use Reactor. Your sole remedy for any
defect or non-conformity is to stop using Reactor. Your use of Reactor (as
evidenced by its presence, in whole or part, on any storage or media of any
type in your posession) constitutes your express agreement to all terms of
this license without reservation, limitation, or exclusion. If you do not
agree to all of the foregoing, or if the laws of your jurisdiction exclude
or limit any of the foregoing terms or conditions, you may not use Reactor
and all rights granted you hereunder are withdrawn, null, and void. Your
violation of any of the terms hereof contemporaneously and summarily
terminates your license and any and all rights granted you hereunder.
--]]
--luacheck: std lua51,module,read globals luup,ignore 542 611 612 614 111/_,no max line length
module("L_Reactor", package.seeall)
local debugMode = false
local _PLUGIN_ID = 9086
local _PLUGIN_NAME = "Reactor"
local _PLUGIN_VERSION = "3.11 (22314)"
local _PLUGIN_URL = "https://www.toggledbits.com/reactor"
local _DOC_URL = "https://www.toggledbits.com/static/reactor/docs/3.9/"
local _FORUM_URL = "https://community.getvera.com/c/plugins-and-plugin-development/reactor/178"
local _CONFIGVERSION = 22145
local _CDATAVERSION = 20045 -- must coincide with JS
local _UIVERSION = 22314 -- must coincide with JS
_SVCVERSION = 20185 -- must coincide with impl file (not local)
local MYSID = "urn:toggledbits-com:serviceId:Reactor"
local MYTYPE = "urn:schemas-toggledbits-com:device:Reactor:1"
local RSSID = "urn:toggledbits-com:serviceId:ReactorSensor"
local RSTYPE = "urn:schemas-toggledbits-com:device:ReactorSensor:1"
local VARSID = "urn:toggledbits-com:serviceId:ReactorValues"
local GRPSID = "urn:toggledbits-com:serviceId:ReactorGroup"
local SENSOR_SID = "urn:micasaverde-com:serviceId:SecuritySensor1"
local SWITCH_SID = "urn:upnp-org:serviceId:SwitchPower1"
local systemReady = false
local sensorState = {}
local tickTasks = {}
local watchData = {}
local luaFunc = {}
local devicesByName = {}
local sceneData = {}
local sceneWaiting = {}
local sceneState = {}
local hasBattery = true
local usesHouseMode = false
local geofenceMode = 0
local geofenceEvent = 0
local maxEvents = 100
local dateFormat = false
local timeFormat = false
local lastMasterTick
local clockStable = true
local clockValid = true
local lastNetCheckTime = 0
local lastNetCheckState = false
local lastInetDaemonUpdate = 0
local netFailCount = 0
local lastProbeSite
local luaEnv -- global state for all runLua actions
local runStamp = 0
local pluginDevice = false
local isALTUI = false
local isOpenLuup = false
local unsafeLua = true
local devVeraAlerts = false
local devVeraTelegram = false
local installPath
local TICKOFFS = 5 -- cond tasks try to run TICKOFFS seconds after top of minute
local TRUESTRINGS = ":y:yes:t:true:on:1:" -- strings that mean true (also numeric ~= 0)
local ARRAYMAX = 100 -- maximum size of an unbounded (luaxp) array (override by providing boundary/size)
local defaultLogLevel = false -- or a number, which is (uh...) the default log level for messages
local _,json = pcall( require, "dkjson" )
local _,socket = pcall( require, "socket" )
local _,mime = pcall( require, "mime" )
local luaxp -- will only be loaded if needed
local luaxp_extfunc = false
local function dump(t, seen)
if t == nil then return "nil" end
seen = seen or {}
local sep = ""
local str = "{ "
for k,v in pairs(t) do
local val
if type(v) == "table" then
if seen[v] then val = "(recursion)"
else
seen[v] = true
val = dump(v, seen)
end
elseif type(v) == "string" then
val = string.format("%q", v)
elseif type(v) == "number" and (math.abs(v-os.time()) <= 86400) then
val = tostring(v) .. "(" .. os.date("%Y-%m-%d.%X", v) .. ")"
else
val = tostring(v)
end
str = str .. sep .. k .. "=" .. val
sep = ", "
end
str = str .. " }"
return str
end
local function L(msg, ...) -- luacheck: ignore 212
local str
local level = defaultLogLevel or 50
if type(msg) == "table" then
str = tostring(msg.prefix or _PLUGIN_NAME) .. ": " .. tostring(msg.msg or msg[1])
level = msg.level or level
else
str = _PLUGIN_NAME .. ": " .. tostring(msg)
end
str = string.gsub(str, "%%(%d+)", function( n )
n = tonumber(n, 10)
if n < 1 or n > #arg then return "nil" end
local val = arg[n]
if type(val) == "table" then
return dump(val)
elseif type(val) == "string" then
return string.format("%q", val)
elseif type(val) == "number" and math.abs(val-os.time()) <= 86400 then
return tostring(val) .. "(" .. os.date("%Y-%m-%d.%X", val) .. ")"
end
return tostring(val)
end
)
luup.log(str, math.max(1,level))
--[[ ???dev if level <= 2 then local f = io.open( "/etc/cmh-ludl/Reactor.log", "a" ) if f then f:write( str .. "\n" ) f:close() end end --]]
if level == 0 then if debug and debug.traceback then luup.log( debug.traceback(), 1 ) end error(str, 2) end
end
local function D(msg, ...)
if debugMode then
local inf = debug and debug.getinfo(2, "Snl") or {}
L( { msg=msg,
prefix=(_PLUGIN_NAME .. "(" ..
(inf.name or string.format("<func@%s>", tostring(inf.linedefined or "?"))) ..
":" .. tostring(inf.currentline or "?") .. ")") }, ... )
end
end
-- An assert() that only functions in debug mode
local function DA(cond, m, ...)
if cond or not debugMode then return end
L({level=0,msg=m or "Assertion failed!"}, ...)
error("assertion failed") -- should be unreachable
end
local function E(msg, ...) L({level=1,msg=msg}, ...) end
local function W(msg, ...) L({level=2,msg=msg}, ...) end
local function T(msg, ...) L(msg, ...) if debug and debug.traceback then luup.log((debug.traceback())) end end
local function timems()
return math.floor( socket.gettime() * 1000 + 0.5 ) / 1000
end
local function getInstallPath()
if not installPath then
installPath = "/etc/cmh-ludl/" -- until we know otherwise
if isOpenLuup then
local loader = require "openLuup.loader"
if loader.find_file then
installPath = loader.find_file( "L_Reactor.lua" ):gsub( "L_Reactor.lua$", "" )
else
installPath = "./" -- punt
end
end
end
return installPath
end
local function file_exists( filepath, leaveOpen )
local fd = io.open( filepath, "r" )
if fd then
if not leaveOpen then
fd:close()
return true
end
return true, fd
end
return false
end
local function split( str, sep )
sep = sep or ","
local arr = {}
if str == nil or #str == 0 then return arr, 0 end
local rest = string.gsub( str or "", "([^" .. sep .. "]*)" .. sep, function( m ) table.insert( arr, m ) return "" end )
table.insert( arr, rest )
return arr, #arr
end
local function urlencode( s )
-- Could add dot per RFC3986; note space becomes %20
-- Take care to only return 1 value (gsub returns 2)
return ( s:gsub( "([^A-Za-z0-9_-])", function( m )
return string.format( "%%%02x", string.byte( m ) ) end
) )
end
-- Shallow copy
local function shallowCopy( t )
local r = {}
for k,v in pairs(t or {}) do
r[k] = v
end
return r
end
-- Find device by number, name or UDN
local function finddevice( dev, tdev )
local vn
if type(dev) == "number" then
vn = ( dev == -1 ) and tdev or dev
elseif type(dev) == "string" then
if dev == "" then return tdev end
dev = string.lower( dev )
if devicesByName[ dev ] ~= nil then
return devicesByName[ dev ]
end
if dev:sub(1,5) == "uuid:" then
for n,d in pairs( luup.devices ) do
if string.lower( d.udn ) == dev then
devicesByName[ dev ] = n
return n
end
end
else
for n,d in pairs( luup.devices ) do
if string.lower( d.description ) == dev then
devicesByName[ dev ] = n
return n
end
end
end
vn = tonumber( dev )
else
return nil
end
return vn
end
local function fdate( t )
if not dateFormat then
dateFormat = luup.attr_get( "date_format", 0 ) or "yy-mm-dd"
dateFormat = dateFormat:gsub( "yy", "%%Y" ):gsub( "mm", "%%m" ):gsub( "dd", "%%d" );
end
return os.date( dateFormat, t )
end
local function ftime( t )
if not timeFormat then
timeFormat = ( "12hr" == luup.attr_get( "timeFormat", 0 ) ) and "%I:%M:%S%p" or "%H:%M:%S"
end
return os.date( timeFormat, t )
end
local function fdatetime( t ) return fdate(t) .. " " .. ftime(t) end
-- Get iterator for child devices matching passed table of attributes
-- (e.g. { device_type="urn:...", category_num=4 })
local function childDevices( prnt, attr )
prnt = prnt or pluginDevice
attr = attr or {}
local prev = nil
return function()
while true do
local n, d = next( luup.devices, prev )
prev = n
if n == nil then return nil end
local matched = d.device_num_parent == prnt
if matched then
for a,v in pairs( attr ) do
if d[a] ~= v then
matched = false
break
end
end
end
if matched then return n,d end
end
end
end
-- Initialize a variable if it does not already exist.
local function initVar( name, dflt, dev, sid )
assert( dev ~= nil and sid ~= nil)
local currVal = luup.variable_get( sid, name, dev )
if currVal == nil then
luup.variable_set( sid, name, tostring(dflt), dev )
return tostring(dflt)
end
return currVal
end
-- Set variable, only if value has changed.
local function setVar( sid, name, val, dev )
val = (val == nil) and "" or tostring(val)
local s = luup.variable_get( sid, name, dev )
if s ~= val then
luup.variable_set( sid, name, val, dev )
end
return s
end
-- Delete a state variable. Newer versions of firmware do this by setting nil;
-- older versions require a request.
local function deleteVar( sid, name, dev )
if luup.variable_get( sid, name, dev ) then
luup.variable_set( sid, name, "", dev )
-- For firmware > 1036/3917/3918/3919 http://wiki.micasaverde.com/index.php/Luup_Lua_extensions#function:_variable_set
luup.variable_set( sid, name, nil, dev )
end
end
-- Get variable with possible default
local function getVar( name, dflt, dev, sid )
assert ( name ~= nil and dev ~= nil )
local s,t = luup.variable_get( sid or RSSID, name, dev )
-- if debugMode and s == nil then T({level=2,msg="Undefined state variable %1/%2 on #%3"}, sid or RSSID, name, dev) end
if s == nil or s == "" then return dflt,0 end
return s,t
end
-- Get variable on Reactor parent
local function getReactorVar( name, dflt, dev ) return getVar( name, dflt, dev or pluginDevice, MYSID ) end
-- Get numeric variable, or return default value if not set or blank
local function getVarNumeric( name, dflt, dev, sid )
assert ( name ~= nil and dev ~= nil )
DA( dflt==nil or type(dflt)=="number", "Supplied default is not numeric or nil" )
local s = getVar( name, dflt, dev, sid )
return type(s)=="number" and s or tonumber(s) or dflt
end
local function getVarBool( name, dflt, dev, sid ) DA(type(dflt)=="boolean", "Supplied default is not boolean") return getVarNumeric( name, dflt and 1 or 0, dev, sid ) ~= 0 end
-- Get var that stores JSON data. Returns data, error flag.
local function getVarJSON( name, dflt, dev, sid )
assert( dev ~= nil and name ~= nil )
local s = getVar( name, "", dev, sid ) -- blank default
if s == "" then return dflt,false end
local data,pos,err = json.decode( s )
if data == nil then return dflt,err,pos,s end
return data,false
end
-- SSL param can be string or CSV; return string or array
local function getSSLListParam( s )
if s:match(",") then return split(s) end
return s ~= "" and s or nil
end
-- Build SSL params table from settings
local function getSSLParams( prefix, pdev, sid )
pdev = pdev or pluginDevice
sid = sid or MYSID
-- Max flexibility: SSLParams may contain a JSON string for the entire params table
local params = getVarJSON( prefix.."SSLParams", false, pdev, sid )
if params ~= false then D("getSSLParams() %1", params) return params end
-- Old school: individual config vars for various settings
params = {}
-- Repititious, but expeditous. If more in future, go table-driven.
local sslLib = require "ssl"
sslLib = sslLib or {}
for _,v in ipairs{ "SSLProtocol", "SSLMode", "SSLVerify", "SSLOptions" } do
initVar( prefix..v, "", pdev, sid )
end
local s = getVar( prefix.."SSLProtocol", ( ( sslLib._VERSION or "0.5" ):match( "^0%.5" ) ) and "tlsv1" or "any", pdev, sid )
params.protocol = s ~= "" and s or nil
s = getVar( prefix.."SSLMode", "client", pdev, sid )
params.mode = s ~= "" and s or nil
s = getVar( prefix.."SSLVerify", "none", pdev, sid )
params.verify = getSSLListParam(s)
s = getVar( prefix.."SSLOptions", "", pdev, sid )
params.options = getSSLListParam(s)
D("getSSLParams() %1", params)
return params
end
local function requireLoadable( module, ... )
D("requireLoadable(%1)")
if package.loaded[module] then return package.loaded[module] end
D("requireLoadable() attempting load")
local st, md = pcall( require, module )
if st and type(md) == "table" then
if not ( md.MODULE_API and md.MODULE_API >= 20223 ) then
E("Loadable module %1 does not have a compatible API (%2) with this version of Reactor (requires %3 or higher)."
, module, md.MODULE_API, 20223)
package.loaded[module] = nil
return nil, "Invalid module API"
end
L("Loaded module %1 version %2 api %3", module, md.VERSION, md.MODULE_API)
if type(md.reactor_module_init) == "function" then
local se,er = pcall( md.reactor_module_init, pluginDevice, { ['log']=L, ['debug']=D, ['trace']=T }, ... )
if not se then W("Loadable module %1 init failed: %2", module, er) end
end
return md
end
D("requireLoadable() package NOT loaded")
return nil, md
end
-- Check system battery (VeraSecure)
local function checkSystemBattery( pdev )
if isOpenLuup then return end
local level, source = "", nil
local f, s
--[[
-- Command line check; see /etc/init.d/platform_init.sh case "MiOS v1"
f = io.open( "/proc/cmdline", "r" )
if f then
s = (f:read("*a") or ""):lower()
s = s:match( "power_source=(%S+)" )
if s == "adaptor" then
source = "utility"
elseif s == "batteries" then
source = "battery"
end
end
-- File check.
if not source then
f = io.open("/tmp/.running_on_batteries", "r")
if f then
f:close()
source = "battery"
else
f = io.open("/tmp/.running_on_adaptor", "r")
if f then
f:close()
source = "utility"
end
end
D("checkSystemBattery() power state via files yields %1", source)
end
--]]
if not source then
f = io.popen("battery get powersource 2>&1") -- powersource=DC mode/Battery mode
if f then
local l = f:read("*a") or ""
f:close()
D("checkSystemBattery() source query returned %1", l)
setVar( MYSID, "ps", table.concat( { l, os.time() }, "|" ), pdev )
if l ~= "" then
s = l:lower():match("powersource=(.*)")
if s then
if s:match( "^batt" ) then source = "battery"
elseif s:match( "^dc mode" ) then source = "utility"
else
W("Battery check returned unrecognized source %1; assuming utility", s)
source = "utility"
end
else
W("Attempt to get power source unexpected result (%1); assuming non-battery system", l)
end
if source then
f = io.popen("battery get level") -- level=%%%
if f then
s = f:read("*a") or ""
D("checkSystemBattery() level query returned %1", s)
level = s:lower():match( "level=(%d+)" ) or ""
f:close()
end
end
else
L("Power source query failed; assuming non-battery system") -- similar but different
end
else
L("Power state query failed; assuming non-battery system") -- similar but different
end
end
if not source then
if hasBattery then L("Turning off battery checks") end
hasBattery = false
end
setVar( MYSID, "SystemPowerSource", source or "", pdev )
setVar( MYSID, "SystemBatteryLevel", level, pdev )
end
local function rateFill( rh, tt )
if tt == nil then tt = os.time() end
local id = math.floor(tt / rh.divid)
local minid = math.floor(( tt-rh.period ) / rh.divid) + 1
for i=minid,id do
if rh.buckets[tostring(i)] == nil then
rh.buckets[tostring(i)] = 0
end
end
local del = {}
for i in pairs(rh.buckets) do
if tonumber(i) < minid then
table.insert( del, i )
end
end
for i in ipairs(del) do
rh.buckets[del[i]] = nil
end
end
-- Initialize a rate-limiting pool and return in. rateTime is the period for
-- rate limiting (default 60 seconds), and rateDiv is the number of buckets
-- in the pool (granularity, default 15 seconds).
local function initRate( rateTime, rateDiv )
if rateTime == nil then rateTime = 60 end
if rateDiv == nil then rateDiv = 15 end
local rateTab = { buckets = { }, period = rateTime, divid = rateDiv }
rateFill( rateTab )
return rateTab
end
-- Bump rate-limit bucket (default 1 count)
local function rateBump( rh, count )
local tt = os.time()
local id = math.floor(tt / rh.divid)
if count == nil then count = 1 end
rateFill( rh, tt )
rh.buckets[tostring(id)] = rh.buckets[tostring(id)] + count
end
-- Check rate limit. Return true if rate for period (set in init)
-- exceeds rateMax, rate over period, and 60-second average.
local function rateLimit( rh, rateMax, bump)
if bump == nil then bump = false end
if bump then
rateBump( rh, 1 ) -- bump fills for us
else
rateFill( rh )
end
-- Get rate
local nb, t = 0, 0
for i in pairs(rh.buckets) do
t = t + rh.buckets[i]
nb = nb + 1
end
local r60 = ( nb < 1 ) and 0 or ( ( t / ( rh.divid * nb ) ) * 60.0 ) -- 60-sec average
return t > rateMax, t, r60
end
-- Set HMT ModeSetting
local function setHMTModeSetting( hmtdev )
local chm = luup.attr_get( 'Mode', 0 ) or "1"
local armed = getVarBool( "Armed", false, hmtdev, SENSOR_SID )
if ( not isOpenLuup ) and ( luup.version_minor <= 1040 ) then
-- Ancient Luup does not disarm correctly per ModeSetting, so on each cycle, reset armed state and
-- set/force ModeSettings accordingly.
luup.call_action( SENSOR_SID, "SetArmed", { newArmedValue="0" }, hmtdev )
armed = false
end
local s = {}
for ix=1,4 do
table.insert( s, string.format( "%d:%s", ix, ( tostring(ix) == chm ) and ( armed and "A" or "" ) or ( armed and "" or "A" ) ) )
end
s = table.concat( s, ";" )
D("setHMTModeSetting(%4) HM=%1 armed=%2; new ModeSetting=%3", chm, armed, s, hmtdev)
luup.variable_set( "urn:micasaverde-com:serviceId:HaDevice1", "ModeSetting", s, hmtdev )
end
--[[
Compute sunrise/set for given date (t, a timestamp), lat/lon (degrees),
elevation (elev in meters). Apply optional twilight adjustment (degrees,
civil=6.0, nautical=12.0, astronomical=18.0). Returns four values: times
(as *nix timestamps) of sunrise, sunset, and solar noon; and the length of
the period in hours (length of day).
Ref: https://en.wikipedia.org/wiki/Sunrise_equation
Ref: https://www.aa.quae.nl/en/reken/zonpositie.html
--]]
function sun( lon, lat, elev, t )
if t == nil then t = os.time() end -- t defaults to now
if elev == nil then elev = 0.0 end -- elev defaults to 0
local tau = 6.283185307179586 -- tau > pi
local pi = tau / 2.0
local rlat = lat * pi / 180.0
local rlon = lon * pi / 180.0
-- Apply TZ offset for JD in local TZ not UTC; truncate time and force noon.
local gmtnow = os.date("!*t", t) -- get GMT as table
local nownow = os.date("*t", t) -- get local as table
gmtnow.isdst = nownow.isdst -- make sure dst agrees
local locale_offset = os.difftime( t, os.time( gmtnow ) )
local n = math.floor( ( t + locale_offset ) / 86400 + 0.5 + 2440587.5 ) - 2451545.0
local N = n - rlon / tau
local M = ( 6.24006 + 0.017202 * N ) % tau
local C = 0.0334196 * math.sin( M ) + 0.000349066 *
math.sin( 2 * M ) + 0.00000523599 * math.sin( 3 * M )
local lam = ( M + C + pi + 1.796593 ) % tau
local Jt = 2451545.0 + N + 0.0053 * math.sin( M ) -
0.0069 * math.sin( 2 * lam )
local decl = math.asin( math.sin( lam ) * math.sin( 0.409105 ) )
function w0( rl, elvm, dang, wid )
wid = wid or 0.0144862
return math.acos( ( math.sin( (-wid) +
( -0.0362330 * math.sqrt( elvm ) / 1.0472 ) ) -
math.sin( rl ) * math.sin( dang ) ) /
( math.cos( rl ) * math.cos( dang ) ) ) end
local tw = 0.104719755 -- 6 deg in rad; each twilight step is 6 deg
local function JE(j) return math.floor( ( j - 2440587.5 ) * 86400 ) end
return { sunrise=JE(Jt-w0(rlat,elev,decl)/tau), sunset=JE(Jt+w0(rlat,elev,decl)/tau),
civdawn=JE(Jt-w0(rlat,elev,decl,tw)/tau), civdusk=JE(Jt+w0(rlat,elev,decl,tw)/tau),
nautdawn=JE(Jt-w0(rlat,elev,decl,2*tw)/tau), nautdusk=JE(Jt+w0(rlat,elev,decl,2*tw)/tau),
astrodawn=JE(Jt-w0(rlat,elev,decl,3*tw)/tau), astrodusk=JE(Jt+w0(rlat,elev,decl,3*tw)/tau) },
JE(Jt), 24*w0(rlat,elev,decl)/pi -- solar noon and day length
end
-- Add, if not already set, a watch on a device and service.
local function addServiceWatch( dev, svc, var, target )
-- Don't watch our own variables--we update them in sequence anyway
dev = tonumber( dev ) or 0
if dev < 1 then return end
if dev == target and svc == VARSID then return end
target = tostring(target)
local watchkey = string.format("%d/%s/%s", dev, svc or "X", var or "X")
if not watchData[watchkey] then
D("addServiceWatch() adding system variable watch for %1", syskey)
luup.variable_watch( "reactorWatch", svc or "X", var or "X", dev )
watchData[watchkey] = {}
end
if not watchData[watchkey][target] then
D("addServiceWatch() subscribing %1 to %2", target, watchkey)
watchData[watchkey][target] = true
-- else D("addServiceWatch() %1 is already subscribed to %2", target, watchkey)
end
end
-- Get sensor state; create empty if it doesn't exist.
local function getSensorState( tdev )
local ts = tostring(tdev)
if not sensorState[ts] then
sensorState[ts] = {}
end
return sensorState[ts]
end
-- Open event log file
local function openEventLogFile( tdev )
local sst = getSensorState( tdev )
if sst.eventLog then
pcall( function() sst.eventLog:close() end )
sst.eventLog = nil
end
local path = getVar( "EventLogPath", getInstallPath(), tdev, RSSID ) .. "ReactorSensor" .. tostring(tdev) .. "-events.log"
sst.eventLogName = nil
if getVarBool( "LogEventsToFile", false, tdev, RSSID ) then
local err,errno
D("openEventLogFile() opening event log file %1", path)
sst.eventLog,err,errno = io.open( path, "a" )
if not sst.eventLog then
L("Failed to open event log for %1 (%2): %4 (%5) %3", luup.devices[tdev].description, tdev, path, err, errno)
sst.eventLog = false -- stop trying
else
sst.eventLogName = path
sst.eventLog:write(string.format("%s ***: Event log opened for %s (#%s)\n",
os.date("%Y-%m-%d %X"), luup.devices[tdev].description, tdev))
end
else
D("openEventLogFile() event log file disabled for this RS %1", tdev)
sst.eventLog = false
sst.eventLogName = nil
os.remove( path )
end
end
-- Add an event to the event list. Prune the list for size.
local function addEvent( t )
local p = t.msg
if p then
p = p:gsub( "%%%(([^%)]+)%)(.)", function( name, spec )
if spec == "q" then
if type(t[name]) == "string" then return string.format("%q", t[name]) end
return tostring(t[name]==nil and "(nil)" or t[name])
elseif spec ~= "s" then
luup.log("addEvent warning: bad format spec in "..t.msg, 2)
end
return tostring(t[name]) or "(nil)"
end)
else
p = dump(t)
end
p = string.format( "%s.%03d: %s", os.date("%Y-%m-%d %H:%M:%S"), math.floor( socket.gettime() * 1000.0 + 0.5 ) % 1000, p)
local dev = t.dev or pluginDevice
local sst = getSensorState( dev )
sst.eventList = sst.eventList or {}
table.insert( sst.eventList, p )
while #sst.eventList > 0 and #sst.eventList > maxEvents do table.remove( sst.eventList, 1 ) end
if sst.eventLog == nil then openEventLogFile( dev ) end
if sst.eventLog then pcall( function()
sst.eventLog:write( p )
sst.eventLog:write( "\n" )
sst.eventLog:flush()
if sst.eventLog:seek() >= ( 1024*getVarNumeric( "EventLogMaxKB", 256, dev, RSSID ) ) then
sst.eventLog:close()
if isOpenLuup then
os.execute("mv '" .. sst.eventLogName .. "' '" .. sst.eventLogName .. ".old'")
else
os.execute("pluto-lzo c '" .. sst.eventLogName .. "' '" .. sst.eventLogName .. ".lzo'")
os.remove( sst.eventLogName )
end
sst.eventLog = nil
sst.eventLogName = nil
end
end) end
end
-- Enabled?
local function isEnabled( dev )
if not getVarBool( "Enabled", true, pluginDevice, MYSID ) then return false end
return getVarBool( "Enabled", true, dev, RSSID )
end
-- Clear a scheduled timer task
local function clearTask( taskid )
D("clearTask(%1)", taskid)
tickTasks[tostring(taskid)] = nil
end
-- Clear all tasks for specific device
local function clearOwnerTasks( owner )
D("clearOwnerTasks(%1)", owner)
local del = {}
for tid,t in pairs( tickTasks ) do
if t.owner == owner then
table.insert( del, tid )
t.when = 0
end
end
for _,tid in ipairs( del ) do
D("clearOwnerTasks() clearing task %1", tickTasks[tid])
clearTask( tid )
end
end
-- Schedule a timer tick for a future (absolute) time. If the time is sooner than
-- any currently scheduled time, the task tick is advanced; otherwise, it is
-- ignored (as the existing task will come sooner), unless repl=true, in which
-- case the existing task will be deferred until the provided time.
local function scheduleTick( tinfo, timeTick, flags )
D("scheduleTick(%1,%2,%3)", tinfo, timeTick, flags)
flags = flags or {}
if type(tinfo) ~= "table" then tinfo = { id=tinfo } end
local tkey = tostring( tinfo.id or error("task ID or obj required") )
assert( not tinfo.args or type(tinfo.args)=="table" )
assert( not tinfo.func or type(tinfo.func)=="function" )
if tickTasks[tkey] then
-- timer already set, update
tickTasks[tkey].func = tinfo.func or tickTasks[tkey].func
tickTasks[tkey].args = tinfo.args or tickTasks[tkey].args
tickTasks[tkey].info = tinfo.info or tickTasks[tkey].info
if timeTick == nil or tickTasks[tkey].when == nil or timeTick < tickTasks[tkey].when or flags.replace then
-- Not scheduled, requested sooner than currently scheduled, or forced replacement
tickTasks[tkey].when = timeTick
end
else
-- New task
assert(tinfo.owner ~= nil) -- required for new task
assert(tinfo.func ~= nil) -- required for new task
tickTasks[tkey] = { id=tostring(tinfo.id), owner=tinfo.owner,
when=timeTick, func=tinfo.func, args=tinfo.args or {},
info=tinfo.info or "" }
D("scheduleTick() new task %1 at %2", tinfo, timeTick)
end
if timeTick == nil then return end -- no next tick for task
-- If new tick is earlier than next plugin tick, reschedule
tickTasks._plugin = tickTasks._plugin or {}
if tickTasks._plugin.when == nil or timeTick < tickTasks._plugin.when then
tickTasks._plugin.when = timeTick
local delay = timeTick - os.time()
if delay < 0 then delay = 0 end
D("scheduleTick() rescheduling plugin tick for %1s to %2", delay, timeTick)
runStamp = runStamp + 1
luup.call_delay( "reactorTick", delay, runStamp )
end
return tkey
end
-- Schedule a timer tick for after a delay (seconds). See scheduleTick above
-- for additional info.
local function scheduleDelay( tinfo, delay, flags )
D("scheduleDelay(%1,%2,%3)", tinfo, delay, flags )
return scheduleTick( tinfo, os.time()+delay, flags )
end
-- Set the status message
local function setMessage(s, dev)
DA( dev ~= nil )
luup.variable_set(RSSID, "Message", s or "", dev)
end
-- Array to map, where f(elem) returns key[,value]
local function map( arr, f, res )
res = res or {}
for ix,x in ipairs( arr ) do
if f then
local k,v = f( x, ix )
res[k] = (v == nil) and x or v
else
res[x] = x
end
end
return res
end
-- Return array of keys for a map (table). Pass array or new is created.
local function getKeys( m, r )
local seen = {}
if r ~= nil then for k,_ in pairs( r ) do seen[k] = true end else r = {} end
for k,_ in pairs( m ) do
if seen[k] == nil then table.insert( r, k ) seen[k] = true end
end
return r
end
-- Return whether item is on list (table as array)
local function isOnList( l, e )
if l == nil or e == nil then return false end
for n,v in ipairs(l) do if v == e then return true, n end end
return false
end
-- We could get really fancy here and track which keys we've seen, etc., but
-- the most common use cases will be small arrays where the overhead of preparing
-- for that kind of efficiency exceeds the benefit it might provide.
local function compareTables( a, b )
for k in pairs( b ) do
if b[k] ~= a[k] then return false end
end
for k in pairs( a ) do
if a[k] ~= b[k] then return false end
end
return true
end
-- Iterator that returns depth-first traversal of condition groups
local function conditionGroups( root )
local d = {}
local k = 0
local function t( g )
for _,c in ipairs( g.conditions or {}) do
if ( c.type or "group" ) == "group" then
t( c )
end
end
table.insert( d, g )
end
t( root )
return function()
k = k + 1
return ( k <= #d ) and d[k] or nil
end
end
-- Traverse all conditions from c down (assuming c is a group)
local function traverse( c, func )
func( c )
if ( "group" == ( c.type or "group" ) ) then
for _,ch in ipairs( c.conditions or {} ) do
traverse( ch, func )
end
end
end
-- Return iterator for variables in eval order
local function variables( cdata )
local ar = {}
for _,v in pairs( cdata.variables or {} ) do
table.insert( ar, v )
end
table.sort( ar, function( a, b )
local i1 = a.index or -1
local i2 = b.index or -1
if i1 == i2 then
return (a.name or ""):lower() < (b.name or ""):lower()
end
return i1 < i2
end )
local ix = 0
return function()
ix = ix + 1
if ix > #ar then return nil end
return ix, ar[ix]
end
end
local function checkVersion(dev)
-- In debug mode, any version is fine.
if debugMode then return true end
local ui7Check = getReactorVar( "UI7Check", "", dev )
if isOpenLuup then
return true
end
if luup.version_branch == 1 and luup.version_major == 7 then
if ui7Check == "" then
-- One-time init for UI7 or better
luup.variable_set( MYSID, "UI7Check", "true", dev )
end
return true
end
E("firmware %1 (%2.%3.%4) not compatible", luup.version,
luup.version_branch, luup.version_major, luup.version_minor)
return false
end
-- runOnce() looks to see if a core state variable exists; if not, a one-time initialization
-- takes place.
local function sensor_runOnce( tdev )
local s = getVarNumeric("Version", 0, tdev, RSSID)
if s == 0 then
L("Sensor %1 (%2) first run, setting up new instance...", tdev, luup.devices[tdev].description)
-- Force this value.
luup.variable_set( "urn:micasaverde-com:serviceId:HaDevice1", "ModeSetting", "1:;2:;3:;4:", tdev )
-- Fix up category and subcategory
luup.attr_set('category_num', 4, tdev)
luup.attr_set('subcategory_num', 0, tdev)
end
initVar( "Enabled", "1", tdev, RSSID )
initVar( "Retrigger", "", tdev, RSSID )
initVar( "Message", "", tdev, RSSID )
initVar( "Trouble", "0", tdev, RSSID )
initVar( "cdata", "###", tdev, RSSID )
initVar( "cstate", "", tdev, RSSID )
initVar( "Runtime", 0, tdev, RSSID )
initVar( "TripCount", 0, tdev, RSSID )
initVar( "RuntimeSince", os.time(), tdev, RSSID )
initVar( "lastacc", os.time(), tdev, RSSID )
initVar( "ContinuousTimer", "", tdev, RSSID )
initVar( "MaxUpdateRate", "", tdev, RSSID )
initVar( "MaxChangeRate", "", tdev, RSSID )
initVar( "UseReactorScenes", "", tdev, RSSID )
initVar( "FailOnTrouble", "", tdev, RSSID )
initVar( "WatchResponseHoldOff", "", tdev, RSSID )
initVar( "LogEventsToFile", "", tdev, RSSID )
initVar( "EventLogMaxKB", "", tdev, RSSID )
initVar( "TestTime", "", tdev, RSSID )
initVar( "TestHouseMode", "", tdev, RSSID )
initVar( "Armed", 0, tdev, SENSOR_SID )
initVar( "Tripped", 0, tdev, SENSOR_SID )
initVar( "ArmedTripped", 0, tdev, SENSOR_SID )
initVar( "LastTrip", 0, tdev, SENSOR_SID )
initVar( "AutoUntrip", 0, tdev, SENSOR_SID )
local currState = getVarNumeric( "Tripped", 0, tdev, SENSOR_SID )
initVar( "Target", currState, tdev, SWITCH_SID )
initVar( "Status", currState, tdev, SWITCH_SID )
-- Consider per-version changes.
if s < 00206 then
deleteVar( RSSID, "sundata", tdev ) -- moved to master
end
-- Remove old and deprecated values
deleteVar( RSSID, "Invert", tdev )
deleteVar( RSSID, "ValueChangeHoldTime", tdev )
deleteVar( RSSID, "ReloadConditionHoldTime", tdev )
if s < 22145 then
setVar( RSSID, "WatchResponseHoldOff", "", tdev )