-
-
Notifications
You must be signed in to change notification settings - Fork 6
/
MQTT send receive.lua
2204 lines (2042 loc) · 102 KB
/
MQTT send receive.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
--[[ CHANGE TO SUIT ENVIRONMENT --]]
local mqttBroker = '192.168.10.21'
local mqttUsername = 'mqtt'
local mqttPassword = 'password'
local lighting = { ['56'] = true, } -- Array of applications that are used for lighting (do not remove should lastLevel not be required)
local checkForChanges = true -- When true the script will periodically check for create/update/delete of object keywords (disable to lower CPU load, and manually restart on keyword change)
local checkChanges = 30 -- Interval in seconds to check for changes to object keywords
--[[ SET AS APPROPRIATE TO SUIT ENVIRONMENT --]]
local entityIdAsIndentifier = true -- If true then entity IDs will be created using the object identifier (e.g. light.bathroom_1_fan), and if false using C-Bus numbering (e.g. light.cbus_mqtt_254_56_10)
local forceChangeId = true -- CAUTION!!! If true, then if the entity ID changes (because entityIdAsIndentifier) then the entities will be removed and re-added, forcing the entity ID to change - this WILL break dashboards/automations/etc.
local removeSaFromStartOfPn = false -- If true then the suggested area will be removed from the beginning of the preferred name (if the pn includes it) - add the keyword 'exactpn' to create an exception when true
local useLastLevel = false -- Whether to store and use lastLevel (these days Home Assistant recalls previous 'on' levels for most things, but not some things, so lastLevel is always used for certain objects, like fan)
local airtopiaSupport = false -- Monitor keyword 'AT' for Airtopia devices - set to true if support is desired
local panasonicSupport = false -- Monitor keyword 'AC' for Panasonic devices - set to true if support is desired
local environmentSupport = false -- Monitor keyword 'ENV' for ESPHome environment sensors - set to true if support is desired
--[[
** NOTE: This script at one stage relied on an event-based script 'MQTT final' or 'MQTT'. This event script is no longer needed, and can be deleted.
** If named correctly, this resident script will disable the event-based script on startup, but even if it is enabled it will not cause any issue.\
--]]
--[[
Resident, zero sleep interval, name: 'MQTT send receive'
Manage CBus, Panasonic and Airtopia AC and environment events for MQTT, and publish discovery topics. Used with Home Assistant.
Documentation available at https://github.com/autoSteve/acMqtt
--]]
--[[
General configuration variables follow. Change as required, but probably no need to.
--]]
local logging = false -- Enable detailed logging
local logms = false -- Display timestamp duration of certain operations
local setCoverLevelAtStop = true -- As the name implies, for tracked covers in level translation mode, set the stop value to the estimated level at stop
local sendHeartbeat = 30 -- Send a heartbeat to the 'Heartbeat' script every n seconds (zero to disable heartbeat)
local heartbeatConditions = { max = 120, } -- At most 120 seconds without a heartbeat or else get restarted
local publishNoLevel = false -- For trigger app, whether to publish a level with no tag as "Level n" or raise an error in the log
local selectExact = true -- For select, if a CBus level other than in the select levels is set then adjust the CBus level to the next higher select level
local blindKey -- If blind fully open is desirable instead of lastlevel, then change to a string contained in every blind object (e.g. 'Blind'), case sensitive
local mqttClientId = 'ac2mqtt' -- The Mosquitto client ID, which must be unique on the broker - this can be set to nil, in which case a unique name will automatically be generated by the broker
local mqttQoS = 2 -- Quality of service for MQTT messages: 0 = only once, 1 = at least once, 2 = exactly once
local mqttJunk = true -- Whether to publish junk before a blank publish message when removing topics (not necessary, but cleans up MQTT Explorer view)
--[[
Timing variables. Adjust to taste if you know what you're doing. These provide a good compromise, but your deployment may vary.
--]]
local busTimeout = 0.1 -- Lower = higher CPU, but better responsiveness (0.05 = 1/20th of a second or 50ms, 0.005 = 5ms)
local mqttTimeout = 0 -- In milliseconds, go with zero unless you know what you're doing
local ignoreTimeout = 2 -- Timeout for stale MQTT ignore messages in seconds (two seconds is a long time...)
--[[
Topic prefixes for read/write/publish. The mqttDiscovery topic is recommended to be set to 'homeassistant/'
for use with HA, which is the default here. The MQTT CBus topics can be called whatever you want, as discovery/subscribe
adjusts. All topic prefixes must end in '/'.
--]]
local mqttCbus = 'cbus/'
local mqttReadTopic = mqttCbus..'read/'
local mqttWriteTopic = mqttCbus..'write/'
local mqttDiscoveryTopic = 'homeassistant/'
--[[
Variables not to be messed with unless you definitely know what you're doing.
--]]
local init = true -- Initialisation
local reconnect = false -- Reconnection does not perform a full publish
local discovery = {} -- MQTT discovery topics lookup
local discoveryDelete = {} -- If duplicate discovery topics are detected on startup then they will be removed
local discoveryName = {} -- Device names (used for rename detection)
local discoveryId = {} -- Device obj_id (used for rename detection)
local mqttDevices = {} -- CBus groups to send MQTT topics for
local ac = {} -- Panasonic AC device details
local acDevices = {} -- Quick lookup to determine whether an object is an AC device
local acBoards = {} -- All physical AC boards (esp32)
local acSense = {} -- Quick lookup for AC sensors
local at = {} -- Airtopia device details
local atDevices = {} -- Quick lookup to determine whether an object is an AT device
local atBoards = {} -- All physical AT boards
local atDiscovery = {} -- AT discovery topics lookup
local atDiscoveryDelete = {} -- If duplicate discovery topics are detected on startup then they will be removed
local env = {} -- Environment device details
local envDevices = {} -- Quick lookup to determine whether an object is an environment device
local envBoards = {} -- All physical environment boards (esp32)
local cbusMessages = {} -- Message queue
local mqttMessages = {} -- Message queue
local ignoreCbus = {} -- To prevent message loops
local ignoreMqtt = {} -- To prevent message loops
local triggers = {} -- Trigger groups and their levels
local selects = {} -- Select groups and their options/levels
local inbound = {} -- Sensors from Home Assistant
local cover = {} -- Quick lookup to determine whether an object is a cover (blind)
local fan = {} -- Quick lookup to determine whether an object is a fan (sweep fan)
local bSensor = {} -- Quick lookup to determine whether an object is a bsensor (a regular lighting group acting as status)
local binarySensor = {} -- Quick lookup to determine whether an object is a binary sensor
local button = {} -- Quick lookup to determine whether an object is a button
local lightingButton = {} -- Quick lookup to determine whether an object is a lighting group as a button
local userParameter = {} -- Quick lookup to determine whether an object is a user parameter
local unitParameter = {} -- Quick lookup to determine whether an object is a unit parameter
local storeLevel = {} -- Force store the last level for certain object types (fan, fan_pct)
local publishAdj = {} -- Holds scale and decimals to apply
local includeUnits = {} -- Holds table of booleans to add sensor unit (%, $, °C, etc.) to MQTT value
local transition = {} -- Covers that are transitioning
local unpublished = {} -- The outstanding set of CBus objects to publish discovery topics for
local unpublishedAt = {} -- The outstanding set of CBus Airtopia objects to publish discovery topics for
local mqttStatus = 2 -- The status of the MQTT connection. Initially disconnected, which will cause an immediate connection. 1=connected, 2=disconnected
local mqttConnected = 0 -- Timestamp of MQTT connection
local imgDefault = { -- Defaults for images - Simple image name, or a table of 'also contains' keywords (which must include an #else entry)
heat = 'mdi:radiator',
blind = 'mdi:blinds',
['under floor'] = {enable = 'mdi:radiator-disabled', ['#else'] = 'mdi:radiator'},
['towel rail'] = {enable = 'mdi:radiator-disabled', ['#else'] = 'mdi:radiator'},
fan = {sweep = 'mdi:ceiling-fan', ['#else'] = 'mdi:fan'},
exhaust = 'mdi:fan',
gate = {open = 'mdi:gate-open', ['#else'] = 'mdi:gate'},
pir = 'mdi:motion-sensor',
temperature = 'mdi:thermometer',
}
local acMsg = { climate = true, select = true, sensor = true, }
local cudRaw = { -- All possible keywords for MQTT types, used in CUD function to exclude unrelated keywords for change detection
'MQTT', 'light', 'switch', 'cover', 'fan', 'fan_pct', 'fanpct', 'sensor', 'binary_sensor', 'binarysensor', 'bsensor', 'isensor', 'button', 'select',
'pn=', 'sa=', 'img=', 'unit=', 'class=', 'dec=', 'scale=', 'on=', 'off=', 'lvl=', 'rate=', 'delay=', 'topic=',
'includeunit', 'preset', 'noleveltranslate', 'exactpn',
}
local cudAll = {} local param for _, param in ipairs(cudRaw) do cudAll[param] = true end cudRaw = nil
-- local coverLevel = {} storage.set('coverLevel', coverLevel) -- Clear stored coverLevel
local coverLevel = storage.get('coverLevel', {}) -- Holds the current level of covers (survives restart)
local lastLevel = storage.get('lastlvl', {}) -- Holds any last levels if desired/forced (survives restart)
local airtopiaCmds = { 'power', 'fan', 'swing', 'mode', 'target_temperature', } -- All command topics (used for clean up)
local atmodes = { 'auto', 'cool', 'heat', 'fan_only', 'dry', 'off', } -- Airtopia modes
local atswings = { 'Off', 'Horizontal only', 'Vertical only', 'Horizontal and Vertical', } -- Airtopia swing modes
local atfans = { 'Auto', '1', '2', '3', '4', } -- Airtopia fan speed (THIS IS UNTESTED, will result in 0, 1, 2, 3, 4 being set in the user parameter in the order of the table)
local airtopiaStates = {} -- Current state topics in use (used for clean up)
local RETAIN = true -- Boolean aliases for MQTT retain and no-retain settings
local NORETAIN = false
local started = socket.gettime()
local heartbeat = started
-- Runtime global variable checking. Globals must be explicitly declared, which will catch variable name typos
local declaredNames = { vprint = true, vprinthex = true, maxgroup = true, mosquitto = true, rr = true, _ = true, }
local function declare(name, initval) rawset(_G, name, initval) declaredNames[name] = true end
local exclude = { ngx = true, }
setmetatable(_G, {
__newindex = function (t, n, v) if not declaredNames[n] then log('Warning: Write to undeclared global variable "'..n..'"') end rawset(t, n, v) end,
__index = function (_, n) if not exclude[n] and not declaredNames[n] then log('Warning: Read undeclared global variable "'..n..'"') end return nil end,
})
--[[
Utility funtions
--]]
local function len(tbl) local i = 0 for _, _ in pairs(tbl) do i = i + 1 end return i end -- Get number of table members
local function hasMembers(tbl) for _, _ in pairs(tbl) do return true end return false end -- Get whether any table members
string.contains = function (text, prefix) local pos = text:find(prefix, 1, true); if pos then return pos >= 1 else return false end end -- Test whether a string contains a substring, ignoring nil
--string.trim = function (s) if s ~= nil then return s:match('^%s*(.-)%s*$') else return nil end end -- Remove leading and trailing spaces
local function sleep(sec) socket.select(nil, nil, sec) end
local function tNetCBus(net) if net == 0 then return 254 else return net end end -- Translate AC network numbering to CBus
local function indexOf(array, value) for i, v in ipairs(array) do if v == value then return i end end return nil end -- Find the index number of an array member
local function lastSlashToUnderscore(alias) local cnt alias, cnt = alias:gsub('/', '_') return alias:gsub('_', '/', cnt-1) end -- Convert the last slash character to an underscore
local function copy(obj, seen) -- Copy tables, 'seen' is for recursive use
if type(obj) ~= 'table' then return obj end
if seen and seen[obj] then return seen[obj] end
local s = seen or {}
local res = setmetatable({}, getmetatable(obj))
s[obj] = res
for k, v in pairs(obj) do res[copy(k, s)] = copy(v, s) end
return res
end
local function equals(o1, o2, ignoreMt) -- Compare two variables (simple, tables, anything). Default ignore metatables.
if ignoreMt == nil then ignoreMt = true end
if o1 == o2 then return true end
local o1Type = type(o1) local o2Type = type(o2)
if o1Type ~= o2Type then return false end if o1Type ~= 'table' then return false end
if not ignoreMt then local mt1 = getmetatable(o1) if mt1 and mt1.__eq then return o1 == o2 end end
local keySet = {}
for key1, value1 in pairs(o1) do local value2 = o2[key1] if value2 == nil or equals(value1, value2, ignoreMt) == false then return false end keySet[key1] = true end
for key2, _ in pairs(o2) do if not keySet[key2] then return false end end
return true
end
local function removeIrrelevant(keywords)
local curr = {}
for _, k in ipairs(keywords:split(',')) do
local parts = k:split('=') if parts[2] ~= nil then parts[1] = parts[1]:trim()..'=' end
if cudAll[parts[1]] then curr[#curr+1] = k end
end
return table.concat(curr, ',')
end
local function hex2float(raw)
if tonumber(raw, 16) == 0 then return 0.0 end
local raw = string.gsub(raw, "(..)", function (x) return string.char(tonumber(x, 16)) end)
local byte1, byte2, byte3, byte4 = string.byte(raw, 1, 4)
local sign = byte1 > 0x7F
local exponent = (byte1 % 0x80) * 0x02 + math.floor(byte2 / 0x80)
local mantissa = ((byte2 % 0x80) * 0x100 + byte3) * 0x100 + byte4
if sign then sign = -1 else sign = 1 end
local n if mantissa == 0 and exponent == 0 then n = sign * 0.0 elseif exponent == 0xFF then if mantissa == 0 then n = sign * math.huge else n = 0.0/0.0 end else n = sign * math.ldexp(1.0 + mantissa / 0x800000, exponent - 0x7F) end
return n
end
local convertDatahex = { -- Convert a datahex value to the event type
[dt.text] = function (dh) return(string.gsub(dh, "(..)", function (x) return string.char(tonumber(x, 16)) end)) end, -- Convert string of hex to string of chars
[dt.string] = function (dh) return(string.gsub(dh, "(..)", function (x) return string.char(tonumber(x, 16)) end)) end,
[dt.uint32] = function (dh) return(tonumber(dh, 16)) end,
[dt.uint16] = function (dh) return(tonumber(dh, 16)) end,
[dt.uint8] = function (dh) return(tonumber(dh, 16)) end,
[dt.int64] = function (dh) return((tonumber(dh, 16) + 2^63) % 2^64 - 2^63) end, -- Convert to twos compliment signed
[dt.int32] = function (dh) return((tonumber(dh, 16) + 2^31) % 2^32 - 2^31) end,
[dt.int16] = function (dh) return((tonumber(dh, 16) + 2^15) % 2^16 - 2^15) end,
[dt.int8] = function (dh) return((tonumber(dh, 16) + 2^7) % 2^8 - 2^7) end,
[dt.bool] = function (dh) return(tonumber(dh, 16) == 1) end,
[dt.float32] = function (dh) return(hex2float(string.sub(dh, 1, 8))) end, -- Only the first eight characters of datahex are needed (measurement and unit parameter add additional data)
-- If LUA >= 5.3, would not need hex2float... Instead return(string.unpack('f', string.pack('i4', '0x'..string.sub(dh, 1, 8))))
-- To consider, currently unsupported... dt.time, dt.date, dt.rgb/dt.uint24, dt.float16
}
--[[
Register with the Heartbeat script
--]]
local function isRegistered() local hbeat = storage.get('heartbeat', {}); local k; for k, _ in pairs(hbeat) do if k == _SCRIPTNAME then return true, hbeat end end return false, hbeat end
if sendHeartbeat > 0 then
-- Check whether registration is required, and if not registered (or conditions changed) then register
local r, hbeat = isRegistered()
if not r or (r and not equals(hbeat[_SCRIPTNAME], heartbeatConditions)) then
local k, v, vals
vals = '' for k, v in pairs(heartbeatConditions) do vals = vals..k..'='..v..' ' end
log('Registering '.._SCRIPTNAME..' with heartbeat of '..vals)
hbeat[_SCRIPTNAME] = heartbeatConditions
storage.set('heartbeat', hbeat)
end
else -- Remove script from hearbeat registration
local r, hbeat = isRegistered() if r then hbeat[_SCRIPTNAME] = nil storage.set('heartbeat', hbeat) end
end
--[[
Legacy event script management
--]]
local eventScripts = db:getall("SELECT name FROM scripting WHERE type = 'event'")
local eventName, finalName, acName, atName, s = nil, nil, nil, nil
for _, s in ipairs(eventScripts) do
if s.name:lower() == 'mqtt' then eventName = s.name end
if s.name:lower() == 'mqtt final' then finalName = s.name end
if s.name:lower() == 'ac' then acName = s.name end
if s.name:lower() == 'at' then atName = s.name end
end
local finalInstalled = finalName ~= nil and not (script.status(finalName) == nil or script.status(finalName) == false)
local eventInstalled = eventName ~= nil and not (script.status(eventName) == nil or script.status(eventName) == false)
local acInstalled = acName ~= nil and not (script.status(acName) == nil or script.status(acName) == false)
local atInstalled = atName ~= nil and not (script.status(atName) == nil or script.status(atName) == false)
if finalInstalled then script.disable(finalName) log('Disabling event script '..finalName..', as no longer required') end
if eventInstalled then script.disable(eventName) log('Disabling event script '..eventName..', as no longer required') end
if acInstalled then script.disable(acName) log('Disabling event script '..acName..', as no longer required') end
if atInstalled then script.disable(atName) log('Disabling event script '..atName..', as no longer required') end
local version = string.split(io.readfile('/lib/genohm-scada/version'):trim(), '.')
--[[
Mosquitto client and call-backs
--]]
local client = require('mosquitto').new(mqttClientId)
if mqttUsername then client:login_set(mqttUsername, mqttPassword) end
client:will_set(mqttCbus..'status', 'offline', mqttQoS, RETAIN) -- Last will and testament is to set status offline
client.ON_CONNECT = function(success)
if success then
log('Connected to Mosquitto broker')
client:publish(mqttCbus..'status', 'online', mqttQoS, RETAIN)
mqttStatus = 1
end
end
client.ON_DISCONNECT = function(...)
log('Mosquitto broker disconnected')
mqttStatus = 2
init = true
reconnect = true
end
client.ON_MESSAGE = function(mid, topic, payload)
if topic:contains(mqttDiscoveryTopic) then
-- Record discovery topics to check for duplication
local parts = string.split(topic, '/')
if payload ~= '' then
if parts[3] ~= nil and parts[3]:contains('cbus_mqtt_') then
local j = json.decode(payload)
local disc = string.split(parts[3], '_')
local cnl
if #disc == 6 then
cnl = disc[6] disc[6] = nil disc = table.concat(disc, '_')
if discovery[disc] == nil then
discovery[disc] = { ['dtype']=parts[2], ['cnl']={ cnl, }, } -- Table of CBus addresses with type as the value, including a list of 'channels'
else
if #discovery[disc].cnl == 0
then log('Warning: Existing duplicate discovery topics for '..disc..' ... '..parts[2]..' and '..discovery[disc].dtype..', so removing all')
discoveryDelete[disc..'/'..parts[2]] = true
discoveryDelete[disc..'_'..cnl..'/'..parts[2]] = true
else
discovery[disc].dtype = parts[2]
discovery[disc].cnl[#discovery[disc].cnl+1] = cnl -- New 'channel' found
end
end
else
disc = parts[3]
if discovery[disc] ~= nil and discovery[disc].dtype ~= parts[2] then
log('Warning: Existing duplicate discovery topics for '..disc..' ... '..parts[2]..' and '..discovery[disc].dtype..', so removing all')
discoveryDelete[disc..'/'..parts[2]] = true
for _, cnl in ipairs(discovery[disc].cnl) do discoveryDelete[disc..'_'..cnl..'/'..discovery[disc].dtype] = true end
else
discovery[disc] = { ['dtype']=parts[2], ['cnl']={}, } -- Table of CBus addresses with type as the value
end
end
discoveryName[disc] = j.dev.name -- Used for rename detection
discoveryId[disc] = j.obj_id -- Used for ID change detection
end
end
else
mqttMessages[#mqttMessages + 1] = { topic=topic, payload=payload } -- Queue the message
end
end
--[[
C-Bus events, only queues a C-Bus message at the end of a ramp
--]]
local function eventCallback(event)
if mqttDevices[event.dst] then
local value, ramp
local parts = string.split(event.dst, '/')
local tp = grp.find(event.dst).datatype
if lighting[parts[2]] then
value = tonumber(string.sub(event.datahex,1,2),16)
local target = tonumber(string.sub(event.datahex,3,4),16)
local ramp = tonumber(string.sub(event.datahex,5,8),16)
if ramp > 0 then
if event.meta == 'admin' then return end
if value ~= target then return end
end
else
if convertDatahex[tp] ~= nil then
value = convertDatahex[tp](event.datahex)
else
log('Error: Unsupported data type '..dt..' for '..event.dst..', content of datahex '..event.datahex..', not setting')
return
end
end
if value == nil then log('Error: nil value for '..event.dst..', which should not happen, ignoring') return end
if tp == dt.float32 then -- For floats don't set if this event is the same value. Compare to five decimal places, because these are an approximation
local pre, comp
if mqttDevices[event.dst].value ~= nil then pre = string.format('%.5f', mqttDevices[event.dst].value) else pre = nil end
comp = string.format('%.5f', value)
if comp == pre then
if logging then log('Not setting '..event.dst..' to '..value..', same as previous value') end
if logging then log('Content of datahex: '..event.datahex..'. Type='..grp.find(event.dst).datatype..'. pre='..pre..'. comp='..comp) end
return
end
end
if logging then log('Setting '..event.dst..' to '..tostring(value)..', previous='..tostring(mqttDevices[event.dst].value)) end
mqttDevices[event.dst].value = value
if type(value) == 'boolean' then value = value and 'ON' or 'OFF' end
cbusMessages[#cbusMessages + 1] = { ['alias']=event.dst, ['net']=tonumber(parts[1]), ['app']=tonumber(parts[2]), ['group']=tonumber(parts[3]), ['value']=value, }
if parts[4] ~= nil then cbusMessages[#cbusMessages].channel = tonumber(parts[4]) end
-- Check whether to set the level as a tracked lastlevel
local function setLastLevel(val)
if val ~= 0 and val ~= lastLevel[event.dst] then
lastLevel[event.dst] = val
if logging then log('Set lastLevel to '..val..' for '..event.dst) end
storage.set('lastlvl', lastLevel)
end
end
if lighting[parts[2]] then
if useLastLevel then
setLastLevel(value)
else
if storeLevel ~= nil and storeLevel[event.dst] then setLastLevel(value) end
end
end
elseif (panasonicSupport and ac[event.dst]) or (airtopiaSupport and at[event.dst]) then
local parts = string.split(event.dst, '/')
local value
local tp = grp.find(event.dst).datatype
if convertDatahex[tp] ~= nil then
value = convertDatahex[tp](event.datahex)
else
log('Error: Unsupported data type '..dt..' for '..event.dst..', content of datahex '..event.datahex..', not setting')
return
end
if logging then log('Setting '..event.dst..' to '..value) end
cbusMessages[#cbusMessages + 1] = { ['alias']=event.dst, ['net']=tonumber(parts[1]), ['app']=tonumber(parts[2]), ['group']=tonumber(parts[3]), ['value']=value, }
end
end
local localbus = require('localbus').new(busTimeout) -- Set up the localbus
localbus:sethandler('groupwrite', eventCallback)
--[[
Publish lighting, user parameter and trigger objects to MQTT
--]]
local function publish(alias, app, level, noPre)
if noPre == nil then noPre = false end
if level == nil then log('Warning: Nil CBus level for '..alias); do return end end
local state = ''
if cover[alias] then
if mqttDevices[alias].noleveltranslate then
state = (tonumber(level) ~= 0) and 'open' or 'closed'
else
-- For CBus blind controllers report level unless it is 5, which is a preset for stop in level translation mode
state = 'stopped'
if level == 5 then level = -1 end
end
else
if tonumber(level) ~= nil then
state = (tonumber(level) ~= 0) and 'ON' or 'OFF'
elseif type(level) == 'boolean' then
level = level and 'ON' or 'OFF'
state = level
else
state = level
end
end
if not userParameter[alias] then
if not binarySensor[alias] then
if bSensor[alias] then -- It's a bSensor
client:publish(mqttReadTopic..alias..'/level', level, mqttQoS, RETAIN)
client:publish(mqttReadTopic..alias..'/state', state, mqttQoS, RETAIN)
if logging then log('Publishing '..mqttReadTopic..alias..' to '..level) end
elseif selects[alias] then -- It's a select
local l
for _, l in ipairs(selects[alias].allLvl) do
if tonumber(level) <= l.lvl then
if selectExact and tonumber(level) ~= l.lvl and app ~= 202 then
-- Current CBus level does not match the select, so optionally adjust the CBus level
if logging then log('Warning: Forcing level to set for select '..alias..' to nearest level '..l.lvl..' ('..tonumber(level)..' requested, but selectExact=true)') end
grp.write(alias, l.lvl)
--SetCBusLevel(net, app, group, l.lvl, 0)
-- Adjusting the level will result in two MQTT publish events, which could be avoided but is not
end
client:publish(mqttReadTopic..alias..'/select', l.sel, mqttQoS, RETAIN)
client:publish(mqttReadTopic..alias..'/level', l.lvl, mqttQoS, RETAIN)
if logging then log('Publishing select '..mqttReadTopic..alias..' to '..l.sel..' ('..l.lvl..')') end
break
end
end
elseif button[alias] then -- It's a button, so do nothing as it has no state
elseif cover[alias] then -- It's a cover, so vary state behaviour based on level translation mode
if mqttDevices[alias].noleveltranslate then
client:publish(mqttReadTopic..alias..'/state', state, mqttQoS, RETAIN)
if level ~= -1 then client:publish(mqttReadTopic..alias..'/level', level, mqttQoS, RETAIN) end
if logging then log('Publishing state and level '..mqttReadTopic..alias..' to '..state..'/'..level) end
else
local v
if hasMembers(mqttDevices[alias].rate) then
state = (coverLevel[alias] ~= 0) and 'open' or 'closed'
v = coverLevel[alias]
else
state = (level ~= 0) and 'open' or 'closed'
v = (level < 0) and 128 or level
end
client:publish(mqttReadTopic..alias..'/state', state, mqttQoS, RETAIN)
client:publish(mqttReadTopic..alias..'/open', v, mqttQoS, RETAIN)
if logging then log('Publishing state and open '..mqttReadTopic..alias..' to '..state..'/'..v) end
end
else -- It's a bog standard group
local v
client:publish(mqttReadTopic..alias..'/state', state, mqttQoS, RETAIN)
if publishAdj[alias] then v = tonumber(string.format('%.'..publishAdj[alias].dec..'f', level * publishAdj[alias].scale)) else v = level end
client:publish(mqttReadTopic..alias..'/level', v, mqttQoS, RETAIN)
if logging then log('Publishing state and level '..mqttReadTopic..alias..' to '..state..'/'..v) end
end
else -- It's a binary sensor / trigger
client:publish(mqttReadTopic..alias..'/state', state, mqttQoS, RETAIN)
client:publish(mqttReadTopic..alias..'/level', level, mqttQoS, RETAIN)
if logging then log('Publishing '..mqttReadTopic..alias..' to '..state..' ('..level..')') end
end
else -- It's a user parameter
local v
if publishAdj[alias] then v = tonumber(string.format('%.'..publishAdj[alias].dec..'f', level * publishAdj[alias].scale)) else v = level end
client:publish(mqttReadTopic..alias..'/level', v, mqttQoS, RETAIN)
if logging then log('Publishing '..mqttReadTopic..alias..' to '..v) end
end
if not noPre then
if app ~= 202 then -- Not trigger
local p = level
if tonumber(level) ~= nil then p = string.format('%.3f', level) end
end
end
end
--[[
Tracking of cover transitions, estimating with time travelled
--]]
local function trackTransitions()
local k, v
local t = socket.gettime()
local kill = {}
for k, v in pairs(transition) do
if t < v.ts then goto next end
local closing = v.state == 'closing'
local rate = closing and tonumber(mqttDevices[k].rate[2]) or tonumber(mqttDevices[k].rate[1])
local increment = (t - v.ts) / rate * 256
if closing then v.level = v.level - increment else v.level = v.level + increment end
coverLevel[k] = math.floor(v.level + 0.5)
v.ts = t
if coverLevel[k] < 0 then coverLevel[k] = 0 elseif coverLevel[k] > 255 then coverLevel[k] = 255 end
if (coverLevel[k] == 0 and closing) or (coverLevel[k] == 255 and not closing) then kill[k] = true end
client:publish(mqttReadTopic..k..'/state', v.state, mqttQoS, RETAIN)
client:publish(mqttReadTopic..k..'/open', coverLevel[k], mqttQoS, RETAIN)
if logging then log('Publishing state and open '..mqttReadTopic..k..' to '..state..'/'..coverLevel[k]) end
::next::
end
if hasMembers(kill) then storage.set('coverLevel', coverLevel) end
for k, _ in pairs(kill) do
client:publish(mqttReadTopic..k..'/state', (coverLevel[k] ~= 0) and 'open' or 'closed', mqttQoS, RETAIN)
transition[k] = nil
end
end
--[[
Publish measurement application objects to MQTT
--]]
local function publishMeasurement(alias, net, group, channel, value)
if value == nil then log('Warning: Nil CBus measurement value for '..alias); do return end end
local units, v
local adjust = publishAdj[alias]
if adjust then v = tonumber(string.format('%.'..adjust.dec..'f', value * adjust.scale)) else v = value end
if includeUnits[alias] then
_, units = GetCBusMeasurement(net, group, channel)
if units == '$' then
v = units..v
elseif units == '%' then
v = v..units
else
v = v..' '..units
end
end
local mAlias = lastSlashToUnderscore(alias)
client:publish(mqttReadTopic..mAlias..'/level', v, mqttQoS, RETAIN)
if logging then log('Publishing measurement '..mqttReadTopic..mAlias..' to '..v) end
end
--[[
Publish unit parameter objects to MQTT
--]]
local function publishUnitParam(alias, value)
if value == nil then log('Warning: Nil unit parameter value for '..alias); do return end end
local adjust = publishAdj[alias]
local v
if adjust then v = tonumber(string.format('%.'..adjust.dec..'f', value * adjust.scale)) else v = value end
local mAlias = lastSlashToUnderscore(alias)
client:publish(mqttReadTopic..mAlias..'/level', v, mqttQoS, RETAIN)
if logging then log('Publishing unit parameter '..mqttReadTopic..mAlias..' to '..v) end
end
--[[
Publish Panasonic ESPHome objects to MQTT
--]]
local function publishAc(alias, level, select)
if level == nil then log('Warning: Nil AC level for '..alias); do return end end
if ac[alias].state ~= level then
if ignoreMqtt[alias] and (socket.gettime() - ignoreMqtt[alias] > ignoreTimeout) then -- Don't worry about older 'ignore' flags
ignoreMqtt[alias] = nil
if logging then log('Ignoring older MQTT ignore flag for '..alias) end
end
if not ignoreMqtt[alias] then
local parts = string.split(ac[alias].name, '-')
local topic = ''
if select == 'func' then
topic = parts[1]..'/climate/panasonic/'..parts[2]..'/command'
elseif select == 'sel' then
topic = parts[1]..'/select/'..parts[2]..'/command'
elseif select == 'sense' then
if logging then log('Warning: Not publishing sensor change for alias='..alias) end
else
log('Invalid AC command for alias='..alias..', select='..select)
end
if topic ~= '' then client:publish(topic, level, mqttQoS, NORETAIN) end
ignoreCbus[alias] = socket.gettime(); if logging then log('Setting ignore CBus for '..alias) end
if logging then log('Published AC '..ac[alias].name..' to '..level) end
else
ignoreMqtt[alias] = nil
-- if logging then log('Intentionally ignoring MQTT publish for '..alias) end
end
ac[alias].state = level
else
ignoreMqtt[alias] = nil
if logging then log('Ignoring MQTT publish for '..alias..' because already at level '..level) end
end
end
--[[
Publish Airtopia MODBUS objects to MQTT
--]]
local function publishAt(alias, level)
if level == nil then log('Warning: Nil AT level for '..alias); do return end end
if at[alias].state ~= level then
if ignoreMqtt[alias] and (socket.gettime() - ignoreMqtt[alias] > ignoreTimeout) then -- Don't worry about older 'ignore' flags
ignoreMqtt[alias] = nil
if logging then log('Ignoring older MQTT ignore flag for '..alias) end
end
if not ignoreMqtt[alias] then
local parts = string.split(at[alias].name, '-')
local topic = 'airtopia/'..parts[1]..'/state/'..parts[2]
local adjust = publishAdj[alias]
if adjust then level = tonumber(string.format('%.'..adjust.dec..'f', level * adjust.scale)) end
client:publish(topic, level, mqttQoS, RETAIN)
if level ~= at[alias].state then ignoreCbus[alias] = socket.gettime(); if logging then log('Setting ignore CBus for '..alias) end end
if logging then log('Published AT '..at[alias].name..' to '..level) end
-- Determine whether a HomeAssistant state topic needs to be also published
local st = nil
local prefix = nil
local dev = nil
for k, v in pairs(atDevices) do
if v == alias then prefix = string.split(k, '-')[1]; st = string.split(k, '-')[2]; break end
end
if st == nil then
elseif st == 'horiz_swing' then
-- Publish swingha
local o = atDevices[prefix..'-vert_swing']
if o ~= nil then
parts = string.split(o, '/'); local net = tonumber(parts[1]); local group = tonumber(parts[3]);
local v = bit.lshift(GetUserParam(net, group), 1)
local topic = 'airtopia/'..prefix..'/state/swingha'
local s = bit.bor(tonumber(level), v) + 1
client:publish(topic, atswings[s], mqttQoS, RETAIN)
end
elseif st == 'vert_swing' then
-- Publish swingha
local o = atDevices[prefix..'-horiz_swing']
if o ~= nil then
parts = string.split(o, '/'); local net = tonumber(parts[1]); local group = tonumber(parts[3]);
local h = GetUserParam(net, group)
local topic = 'airtopia/'..prefix..'/state/swingha'
local v = bit.lshift(tonumber(level), 1)
local s = bit.bor(h, v) + 1
client:publish(topic, atswings[s], mqttQoS, RETAIN)
end
elseif st == 'fan' then
-- Publish fanha
local fanv = atfans[level+1]
local topic = 'airtopia/'..parts[1]..'/state/fanha'
client:publish(topic, fanv, mqttQoS, RETAIN)
end
else
ignoreMqtt[alias] = nil
if logging then log('Intentionally ignoring MQTT publish for '..alias) end
end
at[alias].state = level
else
ignoreMqtt[alias] = nil
if logging then log('Ignoring MQTT publish for '..alias..' because already at level '..level) end
end
end
--[[
Get level name and value
--]]
local function decodeLevel(net, app, group, level, default)
if level:contains('/') then log('Error: Legacy separator "/" being used for '..net..'/'..app..'/'..group..'! Use ":" instead') return nil, nil end
local parts = string.split(level, ':')
local lvl = -1
if #parts == 2 then -- A select option and level
lvl = tonumber(parts[2])
if lvl == nil then log('Error: Invalid lvl= for '..net..'/'..app..'/'..group..', level "'..parts[2]..'" is probably not numeric') return nil, nil end
if lvl < 0 or lvl > 255 then log('Error: Invalid lvl= for '..net..'/'..app..'/'..group..', level "'..parts[2]..'" is outside acceptable range') return nil, nil end
return parts[1], lvl
elseif #parts == 1 and tonumber(parts[1]) then -- Level numbers only
lvl = tonumber(parts[1])
if lvl < 0 or lvl > 255 then log('Error: Invalid lvl= for '..net..'/'..app..'/'..group..', level '..lvl..' is outside acceptable range') return nil, nil end
parts[1] = GetCBusLevelTag(net, app, group, lvl)
if parts[1] == nil then
if default == nil then
log('Error: No level tag for '..net..'/'..app..'/'..group..', level '..lvl..' which is required when specifying numeric only for lvl=') return nil, nil
else
parts[1] = default
log('Warning: Trigger '..net..'/'..app..'/'..group..' has no level tag defined for level '..level..', setting to "'..default..'"')
end
end
return parts[1], lvl
elseif #parts == 1 then -- Level tags only
lvl = GetCBusLevelAddress(net, app, group, parts[1])
if lvl == nil then log('Error: Invalid lvl= for '..net..'/'..app..'/'..group..', level '..parts[1]) return nil end
return parts[1], lvl
end
end
--[[
Get key/value pairs. Returns a keyword if found in 'allow'. (allow, synonym and special parameters are optional).
--]]
local function getKeyValue(alias, tags, _L, synonym, special, allow)
if synonym == nil then synonym = {} end
if special == nil then special = {} end
if allow == nil then allow = {} end
local dType = nil
for k, t in pairs(tags) do
k = k:trim()
if t ~= -1 then
if special[k] ~= nil then special[k] = true end
local v = t:trim()
if _L[k] then
if type(_L[k]) == 'number' then _L[k] = tonumber(v) if _L[k] == nil then error('Error: Bad numeric value for '..alias..', keyword "'..k..'="') end
elseif type(_L[k]) == 'table' then
_L[k] = string.split(v, '/')
local i, tv for i, tv in ipairs(_L[k]) do _L[k][i] = tv:trim() end
else _L[k] = v end
end
else
if synonym[k] then k = synonym[k] end
if special[k] ~= nil then special[k] = true end
if allow[k] then
if dType == nil then dType = k else error('Error: More than one "type" keyword used for '..alias) end
end
end
end
return dType
end
--[[
Build and publish a CBus MQTT discovery topic
--]]
local function addDiscover(net, app, group, channel, tags, name)
-- Build an alias to refer to each group
local alias = net..'/'..app..'/'..group
if channel ~= nil then alias = alias..'/'..channel end
if not name then -- Need a name from tag lookup for everything but measurement app
if channel == nil then
error('Error: nil channel for name '..tostring(name)..' at '..net..'/'..app..'/'..group..'... a name is required')
else
if app == 228 then name = 'Measurement '..alias else name = 'Unit parameter '..alias end
end
end
-- All keywords except MQTT are optional (some exceptions), with 'light' as default discovery type. Defaults:
local _L = {
pn = name, -- Preferred name
sa = '', -- Suggested area
img = '', -- Image
unit = '', -- Units
class = '', -- HomeAssistant class
dec = 2, -- Decimal places
scale = 1, -- Scale (multiplier or divider)
on = 'On', -- bsensor 'on' string
off = 'Off', -- bsensor 'off' string
lvl = {}, -- Levels
rate = {}, -- Rate of open/close for cover
delay = 0, -- Delay before starting cover tracking
topic = '', -- MQTT topic for inbound sensors
}
local synonym = { binarysensor = 'binary_sensor', fanpct = 'fan_pct' }
local special = { includeunit = false, preset = false, dec = false, noleveltranslate = false, exactpn = false, }
local lvl = false
local dType, action, boid, dSa, payload, prefix, tag, k, t
local function getEntity(entity)
if _L.sa ~= '' then
local startIdx, endIdx
repeat
startIdx, endIdx = entity:find(_L.sa, 1, true)
if startIdx and startIdx == 1 then
entity = entity:sub(endIdx + 1):match'^%s*(.*)'
end
until not startIdx or startIdx > 1
end
return entity
end
local function addCommonPayload(payload, oid, entity, name, objId)
-- Add payload common to all
payload.name = json.null
payload.obj_id = objId
payload.uniq_id = oid
payload.avty_t = mqttCbus..'status'
payload.dev = { name=name, ids=_L.sa..' '..entity:trim(), sa=_L.sa, mf='Schneider Electric', mdl='CBus' }
if _L.img ~= '' then payload.ic = _L.img end
payload = json.encode(payload)
return payload
end
local function removeOld(dType, oid)
-- If dType has changed for an existing discovery topic then remove the previous topic(s)
local disc = string.split(oid, '_')
local cnl
if #disc == 6 then
cnl = disc[6] disc[6] = nil disc = table.concat(disc, '_')
if discovery[disc] == nil then
discovery[disc] = { ['dtype']=dType, ['cnl']={ cnl, }, } -- Table of CBus addresses with type as the value, including a list of 'channels'
else
if #discovery[disc].cnl == 0 then discoveryDelete[disc..'/'..discovery[disc].dtype] = true end
discovery[disc].dtype = dType
discovery[disc].cnl[#discovery[disc].cnl+1] = cnl -- New 'channel' found
end
else
disc = oid
if discovery[disc] ~= nil then
if discovery[disc].dtype ~= dType then discoveryDelete[disc..'/'..discovery[disc].dtype] = true end
for _, cnl in ipairs(discovery[disc].cnl) do discoveryDelete[disc..'_'..cnl..'/'..discovery[disc].dtype] = true end
end
discovery[disc] = { ['dtype']=dType, ['cnl']={}, } -- Table of CBus addresses with type as the value
end
end
local function publish(payload, oid, entity, name, objId)
-- Publish to MQTT broker
if _L.sa == '' then dSa = 'no preferred area' else dSa = _L.sa end
if logging then log('Publishing '..mqttDiscoveryTopic..dType..'/'..oid..'/config as '.._L.pn..' in area '..dSa) end
if discoveryName[oid] ~= nil and discoveryName[oid] ~= name then client:publish(mqttDiscoveryTopic..dType..'/'..oid..'/config', '', mqttQoS, RETAIN) end -- Remove old discovery topic
if forceChangeId then
if discoveryId[oid] ~= nil and discoveryId[oid] ~= objId then client:publish(mqttDiscoveryTopic..dType..'/'..oid..'/config', '', mqttQoS, RETAIN) end -- Remove old discovery topic
end
client:publish(mqttDiscoveryTopic..dType..'/'..oid..'/config', payload, mqttQoS, RETAIN)
end
-- Build an OID (measurement application / unit parameter gets a channel as well), also add to mqttDevices
local oid = 'cbus_mqtt_'..tNetCBus(net)..'_'.. app..'_'..group if channel ~= nil then oid = oid..'_'..channel end
mqttDevices[alias].oid = oid
mqttDevices[alias].net = net
mqttDevices[alias].app = app
mqttDevices[alias].group = group
if channel then mqttDevices[alias].channel = channel end
-- Clear any previous lookup table entries
cover[alias] = nil
fan[alias] = nil
bSensor[alias] = nil
binarySensor[alias] = nil
button[alias] = nil
local oldLightingButton = lightingButton[alias] lightingButton[alias] = nil
userParameter[alias] = nil
unitParameter[alias] = nil
storeLevel[alias] = nil
for s, _ in pairs(special) do mqttDevices[s] = nil end
local allow = {
light = {
getPayload = function()
return {stat_t = mqttReadTopic..alias..'/state', cmd_t = mqttWriteTopic..alias..'/switch', bri_stat_t = mqttReadTopic..alias..'/level', bri_cmd_t = mqttWriteTopic..alias..'/ramp', pl_off = 'OFF', on_cmd_type = 'brightness',}
end
},
switch = {
getPayload = function()
return {stat_t = mqttReadTopic..alias..'/state', cmd_t = mqttWriteTopic..alias..'/switch', pl_on = 'ON', pl_off = 'OFF',}
end
},
cover = {
getPayload = function()
cover[alias] = true
if _L.rate[2] == nil then _L.rate[2] = _L.rate[1] end
mqttDevices[alias].rate = _L.rate
mqttDevices[alias].delay = _L.delay
if special.noleveltranslate then
mqttDevices[alias].noleveltranslate = true
return {stat_t = mqttReadTopic..alias..'/state', cmd_t = mqttWriteTopic..alias..'/ramp', pos_open = 255, pos_clsd = 0, pl_open = 'OPEN', pl_cls = 'CLOSE', pos_t = mqttReadTopic..alias..'/level', set_pos_t = mqttWriteTopic..alias..'/ramp',}
else
mqttDevices[alias].noleveltranslate = false
if not hasMembers(_L.rate) then log('Warning: No cover open/cose rate specified for '..alias..'. Transition tracking disabled.') end
if coverLevel[alias] == nil then coverLevel[alias] = grp.getvalue(alias) log('Warning: Initialising cover level for '..alias..' with '..grp.getvalue(alias)..'. This may not be correct.') end
return {stat_t = mqttReadTopic..alias..'/state', cmd_t = mqttWriteTopic..alias..'/ramp', pos_open = 255, pos_clsd = 0, pl_open = 'OPEN', pl_cls = 'CLOSE', pos_t = mqttReadTopic..alias..'/open', set_pos_t = mqttWriteTopic..alias..'/ramp',}
end
end
},
fan = {
getPayload = function()
fan[alias] = true
storeLevel[alias] = true return {
pl_on = 'ON', pl_off = 'OFF',
stat_t = mqttReadTopic..alias..'/state', cmd_t = mqttWriteTopic..alias..'/ramp',
pr_modes = {'off', 'low', 'medium', 'high'},
pr_mode_cmd_t = mqttWriteTopic..alias..'/ramp', pr_mode_cmd_tpl = '{% if value == "off" %} 0 {% elif value == "low" %} 86 {% elif value == "medium" %} 170 {% elif value == "high" %} 255 {% endif %}',
pr_mode_stat_t = mqttReadTopic..alias..'/level', pr_mode_val_tpl = '{% if value | int == 0 %} off {% elif value | int == 86 %} low {% elif value | int == 170 %} medium {% elif value | int == 255 %} high {% endif %}',
}
end
},
fan_pct = {
getPayload = function()
dType = 'fan' fan[alias] = true storeLevel[alias] = true
local payload = {
pl_on = 'ON', pl_off = 'OFF',
stat_t = mqttReadTopic..alias..'/state', cmd_t = mqttWriteTopic..alias..'/ramp',
pct_cmd_t = mqttWriteTopic..alias..'/ramp', pct_cmd_tpl = '{% if value | int == 0 %} 0 {% elif value | int == 1 %} 86 {% elif value | int == 2 %} 170 {% elif value | int == 3 %} 255 {% endif %}',
pct_stat_t = mqttReadTopic..alias..'/level', pct_val_tpl = '{% if value | int == 0 %} 0 {% elif value | int == 86 %} 1 {% elif value | int == 170 %} 2 {% elif value | int == 255 %} 3 {% endif %}',
spd_rng_min = 1,
spd_rng_max = 3,
opt = false,
}
if special.preset then
payload.pr_modes = {'off', 'low', 'medium', 'high'}
payload.pr_mode_cmd_t = mqttWriteTopic..alias..'/ramp'
payload.pr_mode_cmd_tpl = '{% if value == "off" %} 0 {% elif value == "low" %} 86 {% elif value == "medium" %} 170 {% elif value == "high" %} 255 {% endif %}'
payload.pr_mode_stat_t = mqttReadTopic..alias..'/level'
payload.pr_mode_val_tpl = '{% if value | int == 0 %} off {% elif value | int == 86 %} low {% elif value | int == 170 %} medium {% elif value | int == 255 %} high {% endif %}'
end
return payload
end
},
sensor = {
getPayload = function()
local payload
local str = false
if channel ~= nil then payload = { stat_t = mqttReadTopic..net..'/'..app..'/'..group..'_'..channel..'/level', } else payload = { stat_t = mqttReadTopic..alias..'/level', } end
if lighting[tostring(app)] and not special.dec then _L.dec = 0 end
if _L.class ~= '' then payload.dev_cla = _L.class end
if app == 250 then userParameter[alias] = true end
if app == 255 then unitParameter[alias] = true end
if #_L.lvl == 0 then
if app == 250 then _, _ = pcall(function () if tonumber(GetUserParam(net, group)) ~= nil then payload.val_tpl = '{{ value | float | round ('.._L.dec..') }}' end end)
else payload.val_tpl = '{{ value | float | round ('.._L.dec..') }}'
end
if not payload.val_tpl then str = true end
else
str = true
local count = 0
local tpl
for _, level in ipairs(_L.lvl) do
local tag, lvl = decodeLevel(net, app, group, level)
if tag ~= nil then
if count == 0 then tpl = '{% if value | float == '..lvl..' %}'..tag else tpl = tpl..'{% elif value | float == '..lvl..' %}'..tag end
count = count + 1
else
return nil
end
end
payload.val_tpl = tpl..'{% else %}Unknown{% endif %}'
end
if _L.dec ~= 2 or _L.scale ~= 1 then publishAdj[alias] = { dec = _L.dec, scale = _L.scale } end -- Scale, decimals and units only for sensors
if _L.unit ~= '' and not special.includeUnit then payload.unit_of_meas = _L.unit else if not str then payload.unit_of_meas = '' end end
return payload
end
},
binary_sensor = {
getPayload = function()
binarySensor[alias] = true
return {stat_t = mqttReadTopic..alias..'/state', pl_on = 'ON', pl_off = 'OFF',}
end
},
bsensor = {
getPayload = function()
bSensor[alias] = true dType = 'sensor'
return {stat_t = mqttReadTopic..alias..'/level', val_tpl = '{% if value | float == 0 %} '.._L.off..' {% else %} '.._L.on..' {% endif %}',}
end
},
isensor = {
getPayload = function()
if _L.topic == '' then log('Error: topic= keyword not specified for isensor at '..alias..', which is required') return nil end
return 'inbound'
end
},
button = {
getPayload = function()
button[alias] = true
if lighting[tostring(app)] then
lightingButton[alias] = true
return {cmd_t = mqttWriteTopic..alias..'/press',}
elseif app == 202 then
local i
mqttDevices[alias].trigger = {}
mqttDevices[alias].type = 'button'
if #_L.lvl == 0 then for i = 0,255 do _L.lvl[#_L.lvl + 1] = i end end -- If no "lvl=" specified then scan all levels, which is most inefficient!
for _, i in ipairs(_L.lvl) do
local tag, lvl = decodeLevel(net, app, group, i, publishNoLevel and 'Level '..i or nil)
if tag then
boid = oid..'_'..lvl
table.insert(mqttDevices[alias].trigger, boid)
action = tag:gsub("%s+", "_"):lower() -- Replace spaces with underscores
if triggers[group] == nil then triggers[group] = {} end
triggers[group][action] = lvl
if _L.pn == name then prefix = '' else prefix = _L.pn..' ' end
local entity = getEntity(prefix..tag)
local name
if special.exactpn or not removeSaFromStartOfPn then name = prefix..tag else name = entity end
local objId if not entityIdAsIndentifier then objId = boid else objId = (_L.sa..' '..entity:trim()):lower():gsub('[%p%c]',''):gsub(' ','_'):gsub('__','_'):gsub('__','_') end
payload = addCommonPayload({ cmd_t = mqttWriteTopic..alias..'/'..action..'/press', }, boid, entity, name, objId)
removeOld(dType, boid)
publish(payload, boid, entity, name, objId)
end
end
return 'buttons'
else