-
Notifications
You must be signed in to change notification settings - Fork 0
/
L_VVMachine1.lua
556 lines (445 loc) · 17.6 KB
/
L_VVMachine1.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
--[[
L_VVMachine1.lua
Vera plug-in for Vallox MV ventilation machines
Developed by Vpow 2019-
--]]
module("L_VVMachine1", package.seeall)
MYSID = "urn:vpow-com:serviceId:VVMachine1"
local socket = require("socket")
local bit = require('bit')
local sv = { CLOSED = 0, OPEN = 1, IS_CLOSING = 2, ERROR = -1 }
local socketstate = sv.CLOSED
local VVM_ip = ""
local VVM_pollrate = 10 --poll Vallox unit every 10s
local isconnected = false
local pluginDevice = nil
local wsheader = ""
local sv_data = ""
local CellStatusNames = { [0] = 'HEAT RECOVERY', [1] = 'COOL RECOVERY', [2] = 'BYPASS', [3] = 'DEFROST'}
--conversion functions for signals
local vt = {INT = 0, FLO1 = 1, FLO2 = 2, TEMPC = 3, TEMPF = 4}
local vtaction = {
[vt.INT] = function (x) return x end,
[vt.FLO1] = function (x) return x*0.1 end,
[vt.FLO2] = function (x) return x*0.01 end,
[vt.TEMPC] = function (x) return tonumber(string.format("%.1f", (x-27315)*0.01)) end, --0.1 precision is enough for temperatures
[vt.TEMPF] = function (x) return tonumber(string.format("%.1f", (x-27315)*0.018 + 32.0)) end
}
--selected signals from Vallox machine, offset is location in table received by reading metrics
local Fanspeed = {value=0, name='FanSpeed', offset=65, valuetype=vt.INT}
local Extract = {value=0, name='ExtractTemperature', offset=66, valuetype=vt.TEMPC}
local Exhaust = {value=0, name='ExhaustTemperature', offset=67, valuetype=vt.TEMPC}
local Outdoor = {value=0, name='OutdoorTemperature', offset=68, valuetype=vt.TEMPC}
local Supply = {value=0, name='SupplyTemperature', offset=70, valuetype=vt.TEMPC}
local ExtractFanspeed = {value=0, name='ExtractFanspeed', offset=73, valuetype=vt.INT}
local SupplyFanspeed = {value=0, name='SupplyFanspeed', offset=74, valuetype=vt.INT}
local Humidity = {value=0, name='Humidity', offset=75, valuetype=vt.INT}
local State = {value=0, name='State', offset=108, valuetype=vt.INT}
local Mode = {value=0, name='Mode', offset=109, valuetype=vt.INT}
local CellState = {value=0, name='CellState', offset=115, valuetype=vt.INT}
local Fault = {value=0, name='Fault', offset=120, valuetype=vt.INT}
local BoostTimer = {value=0, name='BoostTimer', offset=111, valuetype=vt.INT}
local BoostTime = {value=0, name='BoostTime', offset=247, valuetype=vt.INT}
local BoostTimerEnabled = {value=0, name='BoostTimerEnabled', offset=265, valuetype=vt.INT}
local FireplaceTimer = {value=0, name='FireplaceTimer', offset=112, valuetype=vt.INT}
local FireplaceTime = {value=0, name='FireplaceTime', offset=248, valuetype=vt.INT}
local FireplaceTimerEnabled = {value=0, name='FireplaceTimerEnabled', offset=266, valuetype=vt.INT}
local ExtraTimer = {value=0, name='ExtraTimer', offset=113, valuetype=vt.INT}
local ExtraAirTempTarget = {value=0, name='ExtraAirTempTarget', offset=196, valuetype=vt.TEMPC}
local ExtraExtractFan = {value=0, name='ExtraExtractFan', offset=197, valuetype=vt.INT}
local ExtraSupplyFan = {value=0, name='ExtraSupplyFan', offset=198, valuetype=vt.INT}
local ExtraTime = {value=0, name='ExtraTime', offset=199, valuetype=vt.INT}
local ExtraTimerEnabled = {value=0, name='ExtraTimerEnabled', offset=271, valuetype=vt.INT}
local Vallox_signals = {Extract, Exhaust, Outdoor, Supply, Fanspeed, ExtractFanspeed, SupplyFanspeed, Humidity, State, Mode, BoostTimer, BoostTime, BoostTimerEnabled, FireplaceTimer, FireplaceTime, FireplaceTimerEnabled, ExtraTimer, ExtraAirTempTarget, ExtraExtractFan, ExtraSupplyFan, ExtraTime, ExtraTimerEnabled, CellState, Fault}
------------------------------------------------
-- Debug --
------------------------------------------------
local function log(text, level)
luup.log(string.format("%s: %s", "VVMACHINE", text), (level or 50))
end
------------------------------------------------
-- Helper functions --
------------------------------------------------
local bool_to_number={ [true]=1, [false]=0 }
-- Set variable, only if value has changed.
local function setVar(name, val, dev, sid)
local s = luup.variable_get(sid, name, dev)
if s ~= tostring(val) then --since variable_get returns string, need to convert val to string also
luup.variable_set(sid, name, val, dev)
end
return s -- return old value
end
------------------------------------------------------------------------------------------------------------
-- Vallox ventilation unit control via websockets --
-- websocket implementation originally from reneboer https://github.com/reneboer/vera-Harmony-Hub
------------------------------------------------------------------------------------------------------------
local function VVM_wsconnect(host, port)
if socketstate ~= sv.CLOSED then
socketstate = sv.CLOSED
sock:close()
log("socket force closed")
end
sock = socket.tcp()
local _,err = sock:connect(host,port)
if err then
socketstate = sv.CLOSED
sock:close()
log("connection failed")
return nil,err
end
socketstate = sv.OPEN
sock:settimeout(7,'t')
local _,err = sock:send(wsheader)
if err then
socketstate = sv.CLOSED
sock:close()
log("websocket connection failed")
return nil,err
end
local hdr_ok = false
--check response
repeat
local line,err = sock:receive('*l')
if err then
socketstate = sv.CLOSED
sock:close()
log("websocket reply failed")
return nil,err
end
if line == "HTTP/1.1 101 Switching Protocols" then
hdr_ok = true
end
until line == ''
if not hdr_ok then
socketstate = sv.CLOSED
sock:close()
log("websocket handshake failed")
return nil,'websocket handshake failed'
end
return true,'websocket connection ok'
end
local function xor_mask(encoded,mask,payload)
local transformed,transformed_arr = {},{}
for p=1,payload,2000 do
local last = math.min(p+1999,payload)
local original = {string.byte(encoded,p,last)}
for i=1,#original do
local j = (i-1) % 4 + 1
transformed[i] = bit.bxor(original[i],mask[j])
end
local xored = string.char(unpack(transformed,1,#original))
table.insert(transformed_arr,xored)
end
return table.concat(transformed_arr)
end
local function encode(data,opcode)
local header = (opcode or 1) + 128 -- FIN bit set always
local payload = 128 -- MASK bit set always
local len = #data
local chunks = {}
payload = bit.bor(payload,len)
table.insert(chunks,string.char(header,payload))
local m1 = math.random(0,0xff)
local m2 = math.random(0,0xff)
local m3 = math.random(0,0xff)
local m4 = math.random(0,0xff)
local mask = {m1,m2,m3,m4}
table.insert(chunks,string.char(m1,m2,m3,m4))
table.insert(chunks,xor_mask(data,mask,#data))
return table.concat(chunks)
end
function checksum_16(data)
local c = 0
for i=1, #data/2 do
local j = i*2
local x = tonumber(string.byte(data:sub(j,j)))
j = j-1
local y = tonumber(string.byte(data:sub(j,j)))
c = c + x*256 + y
end
return bit.band(c,0xffff)
end
local function VVM_wssend(cmd)
--[[
message structure
WORD1: number of words in message excluding checksum word
WORD2: command
249 0xf9 COMMAND_WRITE_DATA
246 0xf6 COMMAND_READ_TABLES
WORD3: address
WORD4: value
...
WORDN-2: address
WORDN-1: value
WORDN: checksum
--]]
local data
if cmd == "metrics" then
data = string.char(0x03, 0x00, 0xf6, 0x00, 0x00, 0x00)
elseif cmd == "Home" then
--4609 STATE -> 0, 4612 BOOST_TIMER -> 0, 4613 FIREPLACE_TIMER -> 0, 4614 EXTRA_TIMER -> 0
data = string.char(0x0a, 0x00, 0xf9, 0x00, 0x01, 0x12, 0x00, 0x00, 0x04, 0x12, 0x00, 0x00, 0x05, 0x12, 0x00, 0x00, 0x06, 0x12, 0x00, 0x00)
elseif cmd == "Away" then
--4609 STATE -> 1, 4612 BOOST_TIMER -> 0, 4613 FIREPLACE_TIMER -> 0, 4614 EXTRA_TIMER -> 0
data = string.char(0x0a, 0x00, 0xf9, 0x00, 0x01, 0x12, 0x01, 0x00, 0x04, 0x12, 0x00, 0x00, 0x05, 0x12, 0x00, 0x00, 0x06, 0x12, 0x00, 0x00)
elseif cmd == "Boost" then
--4613 FIREPLACE_TIMER -> 0, 4614 EXTRA_TIMER -> 0, 4612 BOOST_TIMER -> BoostTime
data = string.char(0x08, 0x00, 0xf9, 0x00, 0x05, 0x12, 0x00, 0x00, 0x06, 0x12, 0x00, 0x00, 0x04, 0x12, bit.band(BoostTime.value,0xff), bit.rshift(BoostTime.value,8))
elseif cmd == "Fireplace" then
--4612 BOOST_TIMER -> 0, 4614 EXTRA_TIMER -> 0, 4613 FIREPLACE_TIMER -> FireplaceTime
data = string.char(0x08, 0x00, 0xf9, 0x00, 0x04, 0x12, 0x00, 0x00, 0x06, 0x12, 0x00, 0x00, 0x05, 0x12, bit.band(FireplaceTime.value,0xff), bit.rshift(FireplaceTime.value,8))
elseif cmd == "Extra" then
--4612 BOOST_TIMER -> 0, 4613 FIREPLACE_TIMER -> 0, 4614 EXTRA_TIMER -> ExtraTime
data = string.char(0x08, 0x00, 0xf9, 0x00, 0x04, 0x12, 0x00, 0x00, 0x05, 0x12, 0x00, 0x00, 0x06, 0x12, bit.band(ExtraTime.value,0xff), bit.rshift(ExtraTime.value,8))
elseif cmd == "setvariable" then
data = sv_data
else
return nil,'no command'
end
local csum = checksum_16(data)
data = data .. string.char(bit.band(csum,0xff),bit.rshift(csum,8))
local encoded = encode(data,2)
local n,err = sock:send(encoded)
if err then
log("sending message failed")
return nil,err
end
return true
end
local function VVM_wsreceive()
local chunk,err = sock:receive(2)
local opcode,pllen = chunk:byte(1,2)
-- FIN bit always set, so just substract 128 to get opcode. Mask bit never set.
opcode = opcode - 128
local decoded,err = "",nil
if opcode ==2 and pllen == 126 then --Vallox response uses this size, no need to check bigger ones
local chunk,err = sock:receive(2)
if err then
return nil,err
end
local lb2,lb1 = chunk:byte(1,2)
pllen = lb2*0x100 + lb1
decoded,err = sock:receive(pllen)
if err then
return nil,err
end
else
socketstate = sv.CLOSED
sock:close()
return nil,'Bad response'
end
--all done, socket can be closed
socketstate = sv.CLOSED
sock:close()
return true, decoded
end
local function VVM_ReadMetrics()
local a, b = VVM_wsconnect(VVM_ip,80)
if a ~= nil then
local a, b = VVM_wssend("metrics")
if a ~= nil then
local decoded
a, decoded = VVM_wsreceive()
if a ~= nil then
--take selected signals from message, convert and store to table
for i=1, #Vallox_signals do
local t=tonumber(string.byte(decoded, Vallox_signals[i].offset*2-1),10)*256 + tonumber(string.byte(decoded, Vallox_signals[i].offset*2),10)
Vallox_signals[i].value = vtaction[Vallox_signals[i].valuetype](t)
setVar(Vallox_signals[i].name, Vallox_signals[i].value, pluginDevice, MYSID)
end
--decrypt machine active profile, checking must be done in priority order!
local st = nil
if ExtraTimer.value > 0 then
st = "Extra"
elseif FireplaceTimer.value > 0 then
st = "Fireplace"
elseif BoostTimer.value > 0 then
st = "Boost"
elseif State.value == 0 then
st = "Home"
elseif State.value == 1 then
st = "Away"
end
if st ~=nil then
setVar("Profile",st, dev, MYSID)
end
setVar("Fault",Fault.value, dev, MYSID)
--UI update
local row2
local row3
local row5
if Mode.value == 0 then --mode is normal
--Vera UI is truncating spaces, so in order to keep string lengths as wanted spaces are replaced with empty chars. max. temperature string length is 5 (-xx.x)
row2 = "<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\">" .. (string.gsub(string.format("% 5.1f°C % 5.1f°C %3d%%</span>", Extract.value, Exhaust.value, Fanspeed.value),' ',' '))
row3 = "<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\">" .. (string.gsub(string.format("% 5.1f°C % 5.1f°C %3d%%</span>", Supply.value, Outdoor.value, Humidity.value),' ',' '))
if Fault.value == 1 then
st = 'FAULTED'
else
st = CellStatusNames[CellState.value]
end
else --mode is 'off' or better standby, values are still measured and reported
row2 = "<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\">------------------</span>"
row3 = "<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\">------------------</span>"
st = 'STANDBY'
end
row5 = string.format("<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\"> %s</span>",st)
setVar("UI_row2", row2, pluginDevice, MYSID)
setVar("UI_row3", row3, pluginDevice, MYSID)
setVar("UI_row5", row5, pluginDevice, MYSID)
isconnected = true
else
isconnected = false
log("receiving metrics failed")
end
end
end
setVar("Connected", bool_to_number[isconnected], pluginDevice, MYSID) --change logo according connection status
if not isconnected then --clear display to make sure user sees there is no connection
setVar("UI_row2","<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\"> </span>", pluginDevice, MYSID)
setVar("UI_row3","<span style = \"font-size: 11pt;font-family:monospace;font-weight:bold\"> </span>", pluginDevice, MYSID)
setVar("UI_row5","", pluginDevice, MYSID)
setVar("Profile","none", dev, MYSID) --deselect all buttons
end
end
local function VVM_SetProfile(p)
setVar("Profile", p, dev, MYSID)
local a, b = VVM_wsconnect(VVM_ip,80)
if a ~= nil then
local a, b = VVM_wssend(p)
if a ~= nil then
VVM_wsreceive()
end
end
end
--addr and val must be equal size tables
local function VVM_SetVariable(addr,val)
local a, b = VVM_wsconnect(VVM_ip,80)
if a ~= nil then
local a, b
--construct variable length message without checksum
a = 2+table.getn(addr)*2
sv_data = string.char(bit.band(a,0xff), bit.rshift(a,8), 0xf9, 0x00)
for i=1, #addr do
sv_data = sv_data .. string.char(bit.band(addr[i],0xff), bit.rshift(addr[i],8), bit.band(val[i],0xff), bit.rshift(val[i],8))
end
a, b = VVM_wssend("setvariable")
if a ~= nil then
VVM_wsreceive()
end
end
end
--must be global
function updateUserParams(dev, ser, var, vold, vnew)
--read IP address given by user
local s = luup.variable_get(MYSID, "ValloxIP", pluginDevice)
if s ~= nil then
VVM_ip = s --if IP is not valid, socket will fail anyways
else
--if there is no such variable, create it with default value
luup.variable_set(MYSID, "ValloxIP", "0.0.0.0", pluginDevice)
VVM_ip = ""
end
-- upgrade tp websocket header, use fixed key, need to have one empty line last!
wsheader = 'GET / HTTP/1.1\r\nHost: ' .. VVM_ip .. '\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: 3K4M5P7Q8RATBUCVEXFYG2J3\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n'
log(string.format("IP: %s", VVM_ip))
end
--run once at start, must be global function
function VVM_start(dev)
log("VVMachine plug-in starting")
pluginDevice = dev
updateUserParams()
--in case user changes parameters, have to update internal variables
luup.variable_watch("pluginUpdateParams",MYSID,"ValloxIP", pluginDevice)
--check internal variables, create them if not existing
for i=1, #Vallox_signals do
s = luup.variable_get(MYSID, Vallox_signals[i].name, pluginDevice)
if s ~= nil then
Vallox_signals[i].value = tonumber(s)
else
--if there is no such variable, create it with default value
Vallox_signals[i].value = 0
setVar(Vallox_signals[i].name, Vallox_signals[i].value, pluginDevice, MYSID)
end
end
--UI init
setVar("UI_row1","<span style = \"color:rgb(0,113,184);font-size: 9pt;font-family:monospace;font-weight:bold\"> Indoor ► Exhaust Fan </span>", pluginDevice, MYSID)
setVar("UI_row2","", pluginDevice, MYSID)
setVar("UI_row3","", pluginDevice, MYSID)
setVar("UI_row4","<span style = \"color:rgb(0,113,184);font-size: 9pt;font-family:monospace;font-weight:bold\"> Supply ◄ Outdoor RH </span>", pluginDevice, MYSID)
setVar("UI_row5","", pluginDevice, MYSID)
--all init done, start running the program
luup.call_delay("pluginRun", 0, "")
return true, "ok", "L_VVMachine1"
end
-- run continuously by user given interval, must be global function
function VVM_run()
VVM_ReadMetrics()
luup.call_delay("pluginRun", VVM_pollrate, "")
end
------------------------------------------------
-- Actions -- --must be global functions!
------------------------------------------------
function actionSetProfileHome(dev)
if isconnected then
VVM_SetProfile("Home")
log("set Home")
end
end
function actionSetProfileAway(dev)
if isconnected then
VVM_SetProfile("Away")
log("set Away")
end
end
function actionSetProfileBoost(dev)
if isconnected then
VVM_SetProfile("Boost")
log("set Boost")
end
end
function actionSetProfileFireplace(dev)
if isconnected then
VVM_SetProfile("Fireplace")
log("set Fireplace")
end
end
function actionSetProfileExtra(dev)
if isconnected then
VVM_SetProfile("Extra")
log("set Extra")
end
end
function actionSetOnOff(onoff, dev)
if isconnected then
local x
if onoff == "1" then
x = 0
else
x = 5
end
VVM_SetVariable({4610},{x})
log("set OnOff")
end
end
--addr and val need to be sent as string of comma separated values e.g. "10,22,50"
function actionSetVariable(addr, val, dev)
if isconnected then
if type(addr) == "string" and type(val) == "string" then
local addr_table={}
local val_table={}
--convert strings to tables
for str in string.gmatch(addr, "([^"..','.."]+)") do
table.insert(addr_table, tonumber(str))
end
for str in string.gmatch(val, "([^"..','.."]+)") do
table.insert(val_table, tonumber(str))
end
--check if tables are same size
if table.getn(addr_table) == table.getn(val_table) then
VVM_SetVariable(addr_table,val_table)
log("set Variable")
end
end
end
end
-- END_OF_FILE